diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index 883c5b3866..f9231338c5 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -12,18 +12,14 @@ import 'package:openapi/api.dart'; class MemoryCard extends StatelessWidget { final Asset asset; final void Function() onTap; - final void Function() onClose; final String title; - final String? rightCornerText; final bool showTitle; const MemoryCard({ required this.asset, required this.onTap, - required this.onClose, required this.title, required this.showTitle, - this.rightCornerText, super.key, }); @@ -65,34 +61,34 @@ class MemoryCard extends StatelessWidget { ), GestureDetector( onTap: onTap, - child: ImmichImage( - asset, - fit: BoxFit.fitWidth, - height: double.infinity, - width: double.infinity, - type: ThumbnailFormat.JPEG, - preferredLocalAssetSize: 2048, - ), - ), - Positioned( - top: 2.0, - left: 2.0, - child: IconButton( - onPressed: onClose, - icon: const Icon(Icons.close_rounded), - color: Colors.grey[400], - ), - ), - Positioned( - right: 18.0, - top: 18.0, - child: Text( - rightCornerText ?? "", - style: TextStyle( - color: Colors.grey[200], - fontSize: 12.0, - fontWeight: FontWeight.bold, - ), + child: LayoutBuilder( + builder: (context, constraints) { + // Determine the fit using the aspect ratio + BoxFit fit = BoxFit.fitWidth; + if (asset.width != null && asset.height != null) { + final aspectRatio = asset.width! / asset.height!; + final phoneAspectRatio = + constraints.maxWidth / constraints.maxHeight; + // Look for a 25% difference in either direction + if (phoneAspectRatio * .75 < aspectRatio && + phoneAspectRatio * 1.25 > aspectRatio) { + // Cover to look nice if we have nearly the same aspect ratio + fit = BoxFit.cover; + } + } + + return Hero( + tag: 'memory-${asset.id}', + child: ImmichImage( + asset, + fit: fit, + height: double.infinity, + width: double.infinity, + type: ThumbnailFormat.JPEG, + preferredLocalAssetSize: 2048, + ), + ); + }, ), ), if (showTitle) diff --git a/mobile/lib/modules/memories/ui/memory_epilogue.dart b/mobile/lib/modules/memories/ui/memory_epilogue.dart index 4e32ae6ac5..8dd28637df 100644 --- a/mobile/lib/modules/memories/ui/memory_epilogue.dart +++ b/mobile/lib/modules/memories/ui/memory_epilogue.dart @@ -16,7 +16,7 @@ class _MemoryEpilogueState extends State late final _animationController = AnimationController( vsync: this, duration: const Duration( - seconds: 3, + seconds: 2, ), )..repeat( reverse: true, @@ -29,7 +29,7 @@ class _MemoryEpilogueState extends State super.initState(); _animation = CurvedAnimation( parent: _animationController, - curve: Curves.easeInOut, + curve: Curves.easeIn, ); } @@ -41,74 +41,82 @@ class _MemoryEpilogueState extends State @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, + return SafeArea( + child: Stack( + children: [ + Positioned.fill( + 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, ), - ), - 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, - ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + children: [ + SizedBox( + height: 48, + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, 8 * _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, + ), + ), + ], ), ), - Text( - 'Swipe up to close', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white, - ), - ), - ], - ), - ], + ), + ], + ), ); } } diff --git a/mobile/lib/modules/memories/ui/memory_lane.dart b/mobile/lib/modules/memories/ui/memory_lane.dart index 4e6d4f81a6..1a47d9b661 100644 --- a/mobile/lib/modules/memories/ui/memory_lane.dart +++ b/mobile/lib/modules/memories/ui/memory_lane.dart @@ -16,41 +16,46 @@ class MemoryLane extends HookConsumerWidget { final memoryLane = memoryLaneFutureProvider .whenData( (memories) => memories != null - ? Container( - margin: const EdgeInsets.only(top: 10, left: 10), + ? SizedBox( height: 200, child: ListView.builder( scrollDirection: Axis.horizontal, shrinkWrap: true, itemCount: memories.length, + padding: const EdgeInsets.only( + right: 8.0, + bottom: 8, + top: 10, + left: 10, + ), itemBuilder: (context, index) { final memory = memories[index]; - return Padding( - padding: const EdgeInsets.only(right: 8.0, bottom: 8), - child: GestureDetector( - onTap: () { - HapticFeedback.heavyImpact(); - context.pushRoute( - MemoryRoute( - memories: memories, - memoryIndex: index, + return GestureDetector( + onTap: () { + HapticFeedback.heavyImpact(); + context.pushRoute( + MemoryRoute( + memories: memories, + memoryIndex: index, + ), + ); + }, + child: Stack( + children: [ + Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13.0), ), - ); - }, - child: Stack( - children: [ - Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(13.0), + clipBehavior: Clip.hardEdge, + child: ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.2), + BlendMode.darken, ), - clipBehavior: Clip.hardEdge, - child: ColorFiltered( - colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.2), - BlendMode.darken, - ), + child: Hero( + tag: 'memory-${memory.assets[0].id}', child: ImmichImage( memory.assets[0], fit: BoxFit.cover, @@ -61,25 +66,25 @@ class MemoryLane extends HookConsumerWidget { ), ), ), - Positioned( - bottom: 16, - left: 16, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 114, - ), - child: Text( - memory.title, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: Colors.white, - fontSize: 15, - ), + ), + Positioned( + bottom: 16, + left: 16, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 114, + ), + child: Text( + memory.title, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 15, ), ), ), - ], - ), + ), + ], ), ); }, diff --git a/mobile/lib/modules/memories/ui/memory_progress_indicator.dart b/mobile/lib/modules/memories/ui/memory_progress_indicator.dart new file mode 100644 index 0000000000..697d910a4e --- /dev/null +++ b/mobile/lib/modules/memories/ui/memory_progress_indicator.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class MemoryProgressIndicator extends StatelessWidget { + /// The number of ticks in the progress indicator + final int ticks; + + /// The current value of the indicator + final double value; + + const MemoryProgressIndicator({ + super.key, + required this.ticks, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final tickWidth = constraints.maxWidth / ticks; + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(2.0)), + child: Stack( + children: [ + LinearProgressIndicator( + value: value, + backgroundColor: Colors.grey[600], + color: immichDarkThemePrimaryColor, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + ticks, + (i) => Container( + width: tickWidth, + height: 4, + decoration: BoxDecoration( + border: i == 0 + ? null + : Border( + left: BorderSide( + color: context.colorScheme.onSecondaryContainer, + width: 1, + ), + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index fbc04feae5..26104054c3 100644 --- a/mobile/lib/modules/memories/views/memory_page.dart +++ b/mobile/lib/modules/memories/views/memory_page.dart @@ -7,6 +7,7 @@ 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_epilogue.dart'; +import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:openapi/api.dart' as api; @@ -24,15 +25,28 @@ class MemoryPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final memoryPageController = usePageController(initialPage: memoryIndex); - final memoryAssetPageController = usePageController(); final currentMemory = useState(memories[memoryIndex]); final currentAssetPage = useState(0); + final currentMemoryIndex = useState(memoryIndex); final assetProgress = useState( "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}", ); const bgColor = Colors.black; + /// The list of all of the asset page controllers + final memoryAssetPageControllers = + List.generate(memories.length, (i) => usePageController()); + + /// The main vertically scrolling page controller with each list of memories + final memoryPageController = usePageController(initialPage: memoryIndex); + + // The Page Controller that scrolls horizontally with all of the assets + useEffect(() { + // Memories is an immersive activity + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + return null; + }); + toNextMemory() { memoryPageController.nextPage( duration: const Duration(milliseconds: 500), @@ -43,7 +57,10 @@ class MemoryPage extends HookConsumerWidget { toNextAsset(int currentAssetIndex) { if (currentAssetIndex + 1 < currentMemory.value.assets.length) { // Go to the next asset - memoryAssetPageController.nextPage( + PageController controller = + memoryAssetPageControllers[currentMemoryIndex.value]; + + controller.nextPage( curve: Curves.easeInOut, duration: const Duration(milliseconds: 500), ); @@ -154,67 +171,122 @@ class MemoryPage extends HookConsumerWidget { }, child: Scaffold( backgroundColor: bgColor, - body: SafeArea( - child: PageView.builder( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - scrollDirection: Axis.vertical, - controller: memoryPageController, - onPageChanged: (pageNumber) { - HapticFeedback.mediumImpact(); - if (pageNumber < memories.length) { - currentMemory.value = memories[pageNumber]; - } + body: PopScope( + onPopInvoked: (didPop) { + // Remove immersive mode and go back to normal mode + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + }, + child: SafeArea( + child: PageView.builder( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + scrollDirection: Axis.vertical, + controller: memoryPageController, + onPageChanged: (pageNumber) { + HapticFeedback.mediumImpact(); + if (pageNumber < memories.length) { + currentMemoryIndex.value = pageNumber; + currentMemory.value = memories[pageNumber]; + } - currentAssetPage.value = 0; + currentAssetPage.value = 0; - updateProgressText(); - }, - itemCount: memories.length + 1, - 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 - return Column( - children: [ - Expanded( - child: PageView.builder( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - controller: memoryAssetPageController, - onPageChanged: onAssetChanged, - scrollDirection: Axis.horizontal, - itemCount: memories[mIndex].assets.length, - itemBuilder: (context, index) { - final asset = memories[mIndex].assets[index]; - return Container( - color: Colors.black, - child: MemoryCard( - asset: asset, - onTap: () => toNextAsset(index), - onClose: () => context.popRoute(), - rightCornerText: assetProgress.value, - title: memories[mIndex].title, - showTitle: index == 0, - ), - ); - }, + updateProgressText(); + }, + itemCount: memories.length + 1, + itemBuilder: (context, mIndex) { + // Build last page + if (mIndex == memories.length) { + return MemoryEpilogue( + onStartOver: () => memoryPageController.animateToPage( + 0, + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, ), - ), - MemoryBottomInfo(memory: memories[mIndex]), - ], - ); - }, + ); + } + // Build horizontal page + final assetController = memoryAssetPageControllers[mIndex]; + return Column( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 24.0, + right: 24.0, + top: 8.0, + bottom: 2.0, + ), + child: AnimatedBuilder( + animation: assetController, + builder: (context, child) { + double value = 0.0; + if (assetController.hasClients) { + // We can only access [page] if this has clients + value = assetController.page ?? 0; + } + return MemoryProgressIndicator( + ticks: memories[mIndex].assets.length, + value: (value + 1) / memories[mIndex].assets.length, + ); + }, + ), + ), + Expanded( + child: Stack( + children: [ + PageView.builder( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + controller: assetController, + onPageChanged: onAssetChanged, + scrollDirection: Axis.horizontal, + itemCount: memories[mIndex].assets.length, + itemBuilder: (context, index) { + final asset = memories[mIndex].assets[index]; + return Container( + color: Colors.black, + child: MemoryCard( + asset: asset, + onTap: () => toNextAsset(index), + title: memories[mIndex].title, + showTitle: index == 0, + ), + ); + }, + ), + Positioned( + top: 8, + left: 8, + child: MaterialButton( + minWidth: 0, + onPressed: () { + // auto_route doesn't invoke pop scope, so + // turn off full screen mode here + // https://github.com/Milad-Akarie/auto_route_library/issues/1799 + context.popRoute(); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + ); + }, + shape: const CircleBorder(), + color: Colors.white.withOpacity(0.2), + elevation: 0, + child: const Icon( + Icons.close_rounded, + color: Colors.white, + ), + ), + ), + ], + ), + ), + MemoryBottomInfo(memory: memories[mIndex]), + ], + ); + }, + ), ), ), ),