import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.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_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; @RoutePage() class MemoryPage extends HookConsumerWidget { final List memories; final int memoryIndex; const MemoryPage({ required this.memories, required this.memoryIndex, super.key, }); @override Widget build(BuildContext context, WidgetRef ref) { 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), curve: Curves.easeIn, ); } toNextAsset(int currentAssetIndex) { if (currentAssetIndex + 1 < currentMemory.value.assets.length) { // Go to the next asset PageController controller = memoryAssetPageControllers[currentMemoryIndex.value]; controller.nextPage( curve: Curves.easeInOut, duration: const Duration(milliseconds: 500), ); } else { // Go to the next memory since we are at the end of our assets toNextMemory(); } } updateProgressText() { assetProgress.value = "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}"; } /// Downloads and caches the image for the asset at this [currentMemory]'s index precacheAsset(int index) async { // Guard index out of range if (index < 0) { return; } // Context might be removed due to popping out of Memory Lane during Scroll handling if (!context.mounted) { return; } late Asset asset; if (index < currentMemory.value.assets.length) { // Uses the next asset in this current memory asset = currentMemory.value.assets[index]; } else { // Precache the first asset in the next memory if available final currentMemoryIndex = memories.indexOf(currentMemory.value); // Guard no memory found if (currentMemoryIndex == -1) { return; } final nextMemoryIndex = currentMemoryIndex + 1; // Guard no next memory if (nextMemoryIndex >= memories.length) { return; } // Get the first asset from the next memory asset = memories[nextMemoryIndex].assets.first; } // Gets the thumbnail url and precaches it final precaches = >[]; precaches.add( ImmichImage.precacheAsset( asset, context, type: api.ThumbnailFormat.WEBP, size: 2048, ), ); precaches.add( ImmichImage.precacheAsset( asset, context, type: api.ThumbnailFormat.JPEG, size: 2048, ), ); await Future.wait(precaches); } // Precache the next page right away if we are on the first page if (currentAssetPage.value == 0) { Future.delayed(const Duration(milliseconds: 200)) .then((_) => precacheAsset(1)); } onAssetChanged(int otherIndex) { HapticFeedback.selectionClick(); currentAssetPage.value = otherIndex; precacheAsset(otherIndex + 1); updateProgressText(); } /* 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 * page during the end of scroll is different than the current page */ return NotificationListener( onNotification: (ScrollNotification notification) { // Calculate OverScroll manually using the number of pixels away from maxScrollExtent // maxScrollExtend contains the sum of horizontal pixels of all assets for depth = 1 // or sum of vertical pixels of all memories for depth = 0 if (notification is ScrollUpdateNotification) { final isEpiloguePage = (memoryPageController.page?.floor() ?? 0) >= memories.length; final offset = notification.metrics.pixels; if (isEpiloguePage && (offset > notification.metrics.maxScrollExtent + 150)) { context.popRoute(); return true; } } return false; }, child: Scaffold( backgroundColor: bgColor, 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; 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 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 GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { toNextAsset(index); }, child: Container( color: Colors.black, child: MemoryCard( asset: asset, 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]), ], ); }, ), ), ), ), ); } }