mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01:00
feat(mobile): Add end page to the end to memories (#6780)
* Adding memory epilogue card * Adds epilogue page to memories * Fixes a next / back issue * color --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
9c7dee8551
commit
149bc71eba
4 changed files with 200 additions and 92 deletions
44
mobile/lib/modules/memories/ui/memory_bottom_info.dart
Normal file
44
mobile/lib/modules/memories/ui/memory_bottom_info.dart
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
||||||
|
|
||||||
|
class MemoryBottomInfo extends StatelessWidget {
|
||||||
|
final Memory memory;
|
||||||
|
|
||||||
|
const MemoryBottomInfo({super.key, required this.memory});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final df = DateFormat.yMMMMd();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
memory.title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[400],
|
||||||
|
fontSize: 13.0,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
df.format(
|
||||||
|
memory.assets[0].fileCreatedAt,
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15.0,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,6 @@ import 'dart:ui';
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
@ -10,7 +9,7 @@ import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class MemoryCard extends HookConsumerWidget {
|
class MemoryCard extends StatelessWidget {
|
||||||
final Asset asset;
|
final Asset asset;
|
||||||
final void Function() onTap;
|
final void Function() onTap;
|
||||||
final void Function() onClose;
|
final void Function() onClose;
|
||||||
|
@ -28,20 +27,10 @@ class MemoryCard extends HookConsumerWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
String get authToken => 'Bearer ${Store.get(StoreKey.accessToken)}';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
|
||||||
|
|
||||||
buildTitle() {
|
|
||||||
return Text(
|
|
||||||
title,
|
|
||||||
style: context.textTheme.headlineMedium?.copyWith(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
@ -110,7 +99,13 @@ class MemoryCard extends HookConsumerWidget {
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 18.0,
|
left: 18.0,
|
||||||
bottom: 18.0,
|
bottom: 18.0,
|
||||||
child: buildTitle(),
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: context.textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
114
mobile/lib/modules/memories/ui/memory_epilogue.dart
Normal file
114
mobile/lib/modules/memories/ui/memory_epilogue.dart
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
||||||
|
class MemoryEpilogue extends StatefulWidget {
|
||||||
|
final Function()? onStartOver;
|
||||||
|
|
||||||
|
const MemoryEpilogue({super.key, this.onStartOver});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MemoryEpilogue> createState() => _MemoryEpilogueState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MemoryEpilogueState extends State<MemoryEpilogue>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late final _animationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(
|
||||||
|
seconds: 3,
|
||||||
|
),
|
||||||
|
)..repeat(
|
||||||
|
reverse: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
late final Animation _animation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animation = CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.check_circle_outline_sharp,
|
||||||
|
color: immichDarkThemePrimaryColor,
|
||||||
|
size: 64.0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16.0),
|
||||||
|
Text(
|
||||||
|
'All caught up',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16.0),
|
||||||
|
Text(
|
||||||
|
'Check back tomorrow for more memories',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16.0),
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.onStartOver,
|
||||||
|
child: Text(
|
||||||
|
'Start Over',
|
||||||
|
style: context.textTheme.displayMedium?.copyWith(
|
||||||
|
color: immichDarkThemePrimaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, 5 * _animationController.value),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Icon(
|
||||||
|
size: 32,
|
||||||
|
Icons.expand_less_sharp,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Swipe up to close',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,10 +4,11 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
||||||
|
import 'package:immich_mobile/modules/memories/ui/memory_bottom_info.dart';
|
||||||
import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
|
import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
|
||||||
|
import 'package:immich_mobile/modules/memories/ui/memory_epilogue.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:openapi/api.dart' as api;
|
import 'package:openapi/api.dart' as api;
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
|
@ -26,7 +27,6 @@ class MemoryPage extends HookConsumerWidget {
|
||||||
final memoryPageController = usePageController(initialPage: memoryIndex);
|
final memoryPageController = usePageController(initialPage: memoryIndex);
|
||||||
final memoryAssetPageController = usePageController();
|
final memoryAssetPageController = usePageController();
|
||||||
final currentMemory = useState(memories[memoryIndex]);
|
final currentMemory = useState(memories[memoryIndex]);
|
||||||
final previousMemoryIndex = useState(memoryIndex);
|
|
||||||
final currentAssetPage = useState(0);
|
final currentAssetPage = useState(0);
|
||||||
final assetProgress = useState(
|
final assetProgress = useState(
|
||||||
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
|
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
|
||||||
|
@ -129,39 +129,6 @@ class MemoryPage extends HookConsumerWidget {
|
||||||
updateProgressText();
|
updateProgressText();
|
||||||
}
|
}
|
||||||
|
|
||||||
buildBottomInfo(Memory memory) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
memory.title,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey[400],
|
|
||||||
fontSize: 13.0,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
DateFormat.yMMMMd().format(
|
|
||||||
memory.assets[0].fileCreatedAt,
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 15.0,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||||
* when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final
|
* when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final
|
||||||
* page during the end of scroll is different than the current page
|
* page during the end of scroll is different than the current page
|
||||||
|
@ -172,49 +139,17 @@ class MemoryPage extends HookConsumerWidget {
|
||||||
// maxScrollExtend contains the sum of horizontal pixels of all assets for depth = 1
|
// maxScrollExtend contains the sum of horizontal pixels of all assets for depth = 1
|
||||||
// or sum of vertical pixels of all memories for depth = 0
|
// or sum of vertical pixels of all memories for depth = 0
|
||||||
if (notification is ScrollUpdateNotification) {
|
if (notification is ScrollUpdateNotification) {
|
||||||
|
final isEpiloguePage =
|
||||||
|
(memoryPageController.page?.floor() ?? 0) >= memories.length;
|
||||||
|
|
||||||
final offset = notification.metrics.pixels;
|
final offset = notification.metrics.pixels;
|
||||||
final isLastMemory =
|
if (isEpiloguePage &&
|
||||||
(memories.indexOf(currentMemory.value) + 1) >= memories.length;
|
(offset > notification.metrics.maxScrollExtent + 150)) {
|
||||||
if (isLastMemory) {
|
context.popRoute();
|
||||||
// Vertical scroll handling only at the last asset.
|
return true;
|
||||||
// Tapping on the last asset instead of swiping will trigger the scroll
|
|
||||||
// implicitly which will trigger the below handling and thereby closes the
|
|
||||||
// memory lane as well
|
|
||||||
if (notification.depth == 0) {
|
|
||||||
final isLastAsset = (currentAssetPage.value + 1) ==
|
|
||||||
currentMemory.value.assets.length;
|
|
||||||
if (isLastAsset &&
|
|
||||||
(offset > notification.metrics.maxScrollExtent + 150)) {
|
|
||||||
context.popRoute();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Horizontal scroll handling
|
|
||||||
if (notification.depth == 1 &&
|
|
||||||
(offset > notification.metrics.maxScrollExtent + 100)) {
|
|
||||||
context.popRoute();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.depth == 0) {
|
|
||||||
if (notification is ScrollStartNotification) {
|
|
||||||
assetProgress.value = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
var currentPageNumber = memoryPageController.page!.toInt();
|
|
||||||
currentMemory.value = memories[currentPageNumber];
|
|
||||||
if (notification is ScrollEndNotification) {
|
|
||||||
HapticFeedback.mediumImpact();
|
|
||||||
if (currentPageNumber != previousMemoryIndex.value) {
|
|
||||||
currentAssetPage.value = 0;
|
|
||||||
previousMemoryIndex.value = currentPageNumber;
|
|
||||||
}
|
|
||||||
updateProgressText();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
@ -226,8 +161,28 @@ class MemoryPage extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
controller: memoryPageController,
|
controller: memoryPageController,
|
||||||
itemCount: memories.length,
|
onPageChanged: (pageNumber) {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
if (pageNumber < memories.length) {
|
||||||
|
currentMemory.value = memories[pageNumber];
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAssetPage.value = 0;
|
||||||
|
|
||||||
|
updateProgressText();
|
||||||
|
},
|
||||||
|
itemCount: memories.length + 1,
|
||||||
itemBuilder: (context, mIndex) {
|
itemBuilder: (context, mIndex) {
|
||||||
|
// Build last page
|
||||||
|
if (mIndex == memories.length) {
|
||||||
|
return MemoryEpilogue(
|
||||||
|
onStartOver: () => memoryPageController.animateToPage(
|
||||||
|
0,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
// Build horizontal page
|
// Build horizontal page
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
@ -256,7 +211,7 @@ class MemoryPage extends HookConsumerWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
buildBottomInfo(memories[mIndex]),
|
MemoryBottomInfo(memory: memories[mIndex]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue