mirror of
https://github.com/immich-app/immich.git
synced 2026-02-18 17:20:13 -05:00
The existing implementation for showing asset details uses a bottom sheet, and is not in sync with the preview or scroll intent. Other apps use inline details, which is much cleaner and feels better to use.
344 lines
14 KiB
Dart
344 lines
14 KiB
Dart
import 'package:auto_route/auto_route.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
|
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
|
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
|
|
|
/// Expects [currentAssetNotifier] to be set before navigating to this page
|
|
@RoutePage()
|
|
class DriftMemoryPage extends HookConsumerWidget {
|
|
final List<DriftMemory> memories;
|
|
final int memoryIndex;
|
|
|
|
const DriftMemoryPage({required this.memories, required this.memoryIndex, super.key});
|
|
|
|
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
|
if (memory.assets.isNotEmpty) {
|
|
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
|
|
|
|
if (memory.assets.first.isVideo) {
|
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
}
|
|
}
|
|
}
|
|
|
|
@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;
|
|
final currentAsset = useState<RemoteAsset?>(null);
|
|
|
|
/// 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);
|
|
|
|
useEffect(() {
|
|
// Memories is an immersive activity
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
|
return () {
|
|
// Clean up to normal edge to edge when we are done
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
};
|
|
});
|
|
|
|
toNextMemory() {
|
|
memoryPageController.nextPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn);
|
|
}
|
|
|
|
void toPreviousMemory() {
|
|
if (currentMemoryIndex.value > 0) {
|
|
// Move to the previous memory page
|
|
memoryPageController.previousPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn);
|
|
|
|
// Wait for the next frame to ensure the page is built
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
final previousIndex = currentMemoryIndex.value - 1;
|
|
final previousMemoryController = memoryAssetPageControllers[previousIndex];
|
|
|
|
// Ensure the controller is attached
|
|
if (previousMemoryController.hasClients) {
|
|
previousMemoryController.jumpToPage(memories[previousIndex].assets.length - 1);
|
|
} else {
|
|
// Wait for the next frame until it is attached
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
if (previousMemoryController.hasClients) {
|
|
previousMemoryController.jumpToPage(memories[previousIndex].assets.length - 1);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
toPreviousAsset(int currentAssetIndex) {
|
|
if (currentAssetIndex > 0) {
|
|
// Go to the previous asset
|
|
PageController controller = memoryAssetPageControllers[currentMemoryIndex.value];
|
|
|
|
controller.previousPage(curve: Curves.easeInOut, duration: const Duration(milliseconds: 500));
|
|
} else {
|
|
// Go to the previous memory since we are at the end of our assets
|
|
toPreviousMemory();
|
|
}
|
|
}
|
|
|
|
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 RemoteAsset 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;
|
|
}
|
|
|
|
// Precache the asset
|
|
final size = MediaQuery.sizeOf(context);
|
|
await precacheImage(getFullImageProvider(asset, size: Size(size.width, size.height)), context, size: size);
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
Future<void> onAssetChanged(int otherIndex) async {
|
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
|
currentAssetPage.value = otherIndex;
|
|
updateProgressText();
|
|
|
|
// Wait for page change animation to finish
|
|
await Future.delayed(const Duration(milliseconds: 400));
|
|
// And then precache the next asset
|
|
await precacheAsset(otherIndex + 1);
|
|
|
|
final asset = currentMemory.value.assets[otherIndex];
|
|
currentAsset.value = asset;
|
|
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
|
// if (asset.isVideo || asset.isMotionPhoto) {
|
|
if (asset.isVideo) {
|
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
}
|
|
}
|
|
|
|
/* 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<ScrollNotification>(
|
|
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.maybePop();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: bgColor,
|
|
body: SafeArea(
|
|
child: PageView.builder(
|
|
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
|
scrollDirection: Axis.vertical,
|
|
controller: memoryPageController,
|
|
onPageChanged: (pageNumber) {
|
|
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
|
|
if (pageNumber < memories.length) {
|
|
currentMemoryIndex.value = pageNumber;
|
|
currentMemory.value = memories[pageNumber];
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
DriftMemoryPage.setMemory(ref, 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,
|
|
),
|
|
);
|
|
}
|
|
|
|
final yearsAgo = DateTime.now().year - memories[mIndex].data.year;
|
|
final title = 'years_ago'.t(context: context, args: {'years': yearsAgo.toString()});
|
|
// 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 Stack(
|
|
children: [
|
|
Container(
|
|
color: Colors.black,
|
|
child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0),
|
|
),
|
|
Positioned.fill(
|
|
child: Row(
|
|
children: [
|
|
// Left side of the screen
|
|
Expanded(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap: () {
|
|
toPreviousAsset(index);
|
|
},
|
|
),
|
|
),
|
|
|
|
// Right side of the screen
|
|
Expanded(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap: () {
|
|
toNextAsset(index);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
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.maybePop();
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
},
|
|
shape: const CircleBorder(),
|
|
color: Colors.white.withValues(alpha: 0.2),
|
|
elevation: 0,
|
|
child: const Icon(Icons.close_rounded, color: Colors.white),
|
|
),
|
|
),
|
|
if (currentAsset.value != null && currentAsset.value!.isVideo)
|
|
Positioned(
|
|
bottom: 24,
|
|
right: 32,
|
|
child: Icon(Icons.videocam_outlined, color: Colors.grey[200]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
DriftMemoryBottomInfo(memory: memories[mIndex], title: title),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|