From 4a2cf28882a8cc390cdc78102b992c4f99387371 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 4 Jul 2025 13:49:15 -0500 Subject: [PATCH] feat: memories in new timeline (#19720) * feat: memories sliver * memories lane * display and show memory * fix: get correct memories * naming * pr feedback * use equalsValue for visibility --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/memory.model.dart | 74 +--- .../lib/domain/services/memory.service.dart | 15 + .../repositories/memory.repository.dart | 60 ++- .../pages/dev/main_timeline.page.dart | 20 +- .../presentation/pages/drift_memory.page.dart | 394 ++++++++++++++++++ .../memory/memory_bottom_info.widget.dart | 64 +++ .../widgets/memory/memory_card.widget.dart | 159 +++++++ .../widgets/memory/memory_lane.widget.dart | 115 +++++ .../widgets/timeline/scrubber.widget.dart | 8 +- .../widgets/timeline/timeline.widget.dart | 17 +- .../infrastructure/memory.provider.dart | 27 ++ mobile/lib/providers/memory.provider.dart | 6 - mobile/lib/routing/router.dart | 8 + mobile/lib/routing/router.gr.dart | 52 +++ mobile/lib/utils/hooks/blurhash_hook.dart | 13 + 15 files changed, 958 insertions(+), 74 deletions(-) create mode 100644 mobile/lib/domain/services/memory.service.dart create mode 100644 mobile/lib/presentation/pages/drift_memory.page.dart create mode 100644 mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart create mode 100644 mobile/lib/presentation/widgets/memory/memory_card.widget.dart create mode 100644 mobile/lib/presentation/widgets/memory/memory_lane.widget.dart create mode 100644 mobile/lib/providers/infrastructure/memory.provider.dart diff --git a/mobile/lib/domain/models/memory.model.dart b/mobile/lib/domain/models/memory.model.dart index d24e1ae1c6..ba2a43428f 100644 --- a/mobile/lib/domain/models/memory.model.dart +++ b/mobile/lib/domain/models/memory.model.dart @@ -1,6 +1,10 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; +import 'package:collection/collection.dart'; + +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + enum MemoryTypeEnum { // do not change this order! onThisDay, @@ -53,7 +57,7 @@ class MemoryData { } // Model for a memory stored in the server -class Memory { +class DriftMemory { final String id; final DateTime createdAt; final DateTime updatedAt; @@ -68,8 +72,9 @@ class Memory { final DateTime? seenAt; final DateTime? showAt; final DateTime? hideAt; + final List assets; - const Memory({ + const DriftMemory({ required this.id, required this.createdAt, required this.updatedAt, @@ -82,9 +87,10 @@ class Memory { this.seenAt, this.showAt, this.hideAt, + required this.assets, }); - Memory copyWith({ + DriftMemory copyWith({ String? id, DateTime? createdAt, DateTime? updatedAt, @@ -97,8 +103,9 @@ class Memory { DateTime? seenAt, DateTime? showAt, DateTime? hideAt, + List? assets, }) { - return Memory( + return DriftMemory( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, @@ -111,64 +118,19 @@ class Memory { seenAt: seenAt ?? this.seenAt, showAt: showAt ?? this.showAt, hideAt: hideAt ?? this.hideAt, + assets: assets ?? this.assets, ); } - Map toMap() { - return { - 'id': id, - 'createdAt': createdAt.millisecondsSinceEpoch, - 'updatedAt': updatedAt.millisecondsSinceEpoch, - 'deletedAt': deletedAt?.millisecondsSinceEpoch, - 'ownerId': ownerId, - 'type': type.index, - 'data': data.toMap(), - 'isSaved': isSaved, - 'memoryAt': memoryAt.millisecondsSinceEpoch, - 'seenAt': seenAt?.millisecondsSinceEpoch, - 'showAt': showAt?.millisecondsSinceEpoch, - 'hideAt': hideAt?.millisecondsSinceEpoch, - }; - } - - factory Memory.fromMap(Map map) { - return Memory( - id: map['id'] as String, - createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int), - updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int), - deletedAt: map['deletedAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(map['deletedAt'] as int) - : null, - ownerId: map['ownerId'] as String, - type: MemoryTypeEnum.values[map['type'] as int], - data: MemoryData.fromMap(map['data'] as Map), - isSaved: map['isSaved'] as bool, - memoryAt: DateTime.fromMillisecondsSinceEpoch(map['memoryAt'] as int), - seenAt: map['seenAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(map['seenAt'] as int) - : null, - showAt: map['showAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(map['showAt'] as int) - : null, - hideAt: map['hideAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(map['hideAt'] as int) - : null, - ); - } - - String toJson() => json.encode(toMap()); - - factory Memory.fromJson(String source) => - Memory.fromMap(json.decode(source) as Map); - @override String toString() { - return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt)'; + return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt, assets: $assets)'; } @override - bool operator ==(covariant Memory other) { + bool operator ==(covariant DriftMemory other) { if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; return other.id == id && other.createdAt == createdAt && @@ -181,7 +143,8 @@ class Memory { other.memoryAt == memoryAt && other.seenAt == seenAt && other.showAt == showAt && - other.hideAt == hideAt; + other.hideAt == hideAt && + listEquals(other.assets, assets); } @override @@ -197,6 +160,7 @@ class Memory { memoryAt.hashCode ^ seenAt.hashCode ^ showAt.hashCode ^ - hideAt.hashCode; + hideAt.hashCode ^ + assets.hashCode; } } diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart new file mode 100644 index 0000000000..c94b8a9f0a --- /dev/null +++ b/mobile/lib/domain/services/memory.service.dart @@ -0,0 +1,15 @@ +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:logging/logging.dart'; + +class DriftMemoryService { + final log = Logger("DriftMemoryService"); + + final DriftMemoryRepository _repository; + + DriftMemoryService(this._repository); + + Future> getMemoryLane(String ownerId) { + return _repository.getAll(ownerId); + } +} diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 1f70f9dcd2..ff5f75c2ac 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -1,25 +1,68 @@ import 'package:drift/drift.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/infrastructure/entities/memory.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; class DriftMemoryRepository extends DriftDatabaseRepository { final Drift _db; const DriftMemoryRepository(this._db) : super(_db); - Future> getAll(String userId) { - final query = _db.memoryEntity.select() - ..where((e) => e.ownerId.equals(userId)); + Future> getAll(String ownerId) async { + final now = DateTime.now(); + final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0); - return query.map((memory) { - return memory.toDto(); - }).get(); + final query = _db.select(_db.memoryEntity).join([ + leftOuterJoin( + _db.memoryAssetEntity, + _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id), + ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.memoryAssetEntity.assetId) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline), + ), + ]) + ..where(_db.memoryEntity.ownerId.equals(ownerId)) + ..where(_db.memoryEntity.deletedAt.isNull()) + ..where( + _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc), + ) + ..where( + _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc), + ) + ..orderBy([ + OrderingTerm.desc(_db.memoryEntity.memoryAt), + OrderingTerm.asc(_db.remoteAssetEntity.createdAt), + ]); + + final rows = await query.get(); + + final Map memoriesMap = {}; + + for (final row in rows) { + final memory = row.readTable(_db.memoryEntity); + final asset = row.readTable(_db.remoteAssetEntity); + + final existingMemory = memoriesMap[memory.id]; + if (existingMemory != null) { + existingMemory.assets.add(asset.toDto()); + } else { + final assets = [asset.toDto()]; + memoriesMap[memory.id] = memory.toDto().copyWith(assets: assets); + } + } + + return memoriesMap.values.toList(); } } extension on MemoryEntityData { - Memory toDto() { - return Memory( + DriftMemory toDto() { + return DriftMemory( id: id, createdAt: createdAt, updatedAt: updatedAt, @@ -32,6 +75,7 @@ extension on MemoryEntityData { seenAt: seenAt, showAt: showAt, hideAt: hideAt, + assets: [], ); } } diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 090db4f6ba..9ec8002463 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -1,7 +1,9 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; @RoutePage() class MainTimelinePage extends ConsumerWidget { @@ -9,6 +11,22 @@ class MainTimelinePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return const Timeline(); + final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); + + return memoryLaneProvider.when( + data: (memories) { + return memories.isEmpty + ? const Timeline() + : Timeline( + topSliverWidget: SliverToBoxAdapter( + key: Key('memory-lane-${memories.first.assets.first.id}'), + child: DriftMemoryLane(memories: memories), + ), + topSliverWidgetHeight: 200, + ); + }, + loading: () => const Timeline(), + error: (error, stackTrace) => const Timeline(), + ); } } diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart new file mode 100644 index 0000000000..7da2d1a4c7 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -0,0 +1,394 @@ +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/current_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 memories; + final int memoryIndex; + + const DriftMemoryPage({ + 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; + final currentAsset = useState(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 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( + 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]; + } + + 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, + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart new file mode 100644 index 0000000000..79e6288a72 --- /dev/null +++ b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart @@ -0,0 +1,64 @@ +// ignore_for_file: require_trailing_commas + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; + +import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart'; + +class DriftMemoryBottomInfo extends StatelessWidget { + final DriftMemory memory; + final String title; + const DriftMemoryBottomInfo({ + super.key, + required this.memory, + required this.title, + }); + + @override + Widget build(BuildContext context) { + final df = DateFormat.yMMMMd(); + final fileCreatedDate = memory.assets.first.createdAt; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: Colors.grey[400], + fontSize: 13.0, + fontWeight: FontWeight.w500, + ), + ), + Text( + df.format(fileCreatedDate), + style: const TextStyle( + color: Colors.white, + fontSize: 15.0, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + MaterialButton( + minWidth: 0, + onPressed: () { + context.maybePop(); + scrollToDateNotifierProvider.scrollToDate(fileCreatedDate); + }, + shape: const CircleBorder(), + color: Colors.white.withValues(alpha: 0.2), + elevation: 0, + child: const Icon( + Icons.open_in_new, + color: Colors.white, + ), + ), + ]), + ); + } +} diff --git a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart new file mode 100644 index 0000000000..8268196089 --- /dev/null +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -0,0 +1,159 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; + +class DriftMemoryCard extends StatelessWidget { + final RemoteAsset asset; + final String title; + final bool showTitle; + final Function()? onVideoEnded; + + const DriftMemoryCard({ + required this.asset, + required this.title, + required this.showTitle, + this.onVideoEnded, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.black, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(25.0)), + side: BorderSide( + color: Colors.black, + width: 1.0, + ), + ), + clipBehavior: Clip.hardEdge, + child: Stack( + children: [ + SizedBox.expand( + child: _BlurredBackdrop(asset: asset), + ), + LayoutBuilder( + builder: (context, constraints) { + // Determine the fit using the aspect ratio + BoxFit fit = BoxFit.contain; + 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; + } + } + + if (asset.isImage) { + return Hero( + tag: 'memory-${asset.id}', + child: FullImage( + asset, + fit: fit, + size: const Size(double.infinity, double.infinity), + ), + ); + } else { + return Hero( + tag: 'memory-${asset.id}', + // child: SizedBox( + // width: context.width, + // height: context.height, + // child: NativeVideoViewerPage( + // key: ValueKey(asset.id), + // asset: asset, + // showControls: false, + // playbackDelayFactor: 2, + // image: ImmichImage( + // asset, + // width: context.width, + // height: context.height, + // fit: BoxFit.contain, + // ), + // ), + // ), + child: FullImage( + asset, + fit: fit, + size: const Size(double.infinity, double.infinity), + ), + ); + } + }, + ), + if (showTitle) + Positioned( + left: 18.0, + bottom: 18.0, + child: Text( + title, + style: context.textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +class _BlurredBackdrop extends HookWidget { + final RemoteAsset asset; + + const _BlurredBackdrop({required this.asset}); + + @override + Widget build(BuildContext context) { + final blurhash = useDriftBlurHashRef(asset).value; + if (blurhash != null) { + // Use a nice cheap blur hash image decoration + return Container( + decoration: BoxDecoration( + image: DecorationImage( + image: MemoryImage( + blurhash, + ), + fit: BoxFit.cover, + ), + ), + child: Container( + color: Colors.black.withValues(alpha: 0.2), + ), + ); + } else { + // Fall back to using a more expensive image filtered + // Since the ImmichImage is already precached, we can + // safely use that as the image provider + return ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: getFullImageProvider( + asset, + size: Size(context.width, context.height), + ), + fit: BoxFit.cover, + ), + ), + child: Container( + color: Colors.black.withValues(alpha: 0.2), + ), + ), + ); + } + } +} diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart new file mode 100644 index 0000000000..aa21f36dd1 --- /dev/null +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -0,0 +1,115 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/thumbnail.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/current_asset.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class DriftMemoryLane extends ConsumerWidget { + final List memories; + + const DriftMemoryLane({super.key, required this.memories}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + ), + child: CarouselView( + itemExtent: 145.0, + shrinkExtent: 1.0, + elevation: 2, + backgroundColor: Colors.black, + overlayColor: WidgetStateProperty.all( + Colors.white.withValues(alpha: 0.1), + ), + onTap: (index) { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + + if (memories[index].assets.isNotEmpty) { + final asset = memories[index].assets[0]; + ref.read(currentAssetNotifier.notifier).setAsset(asset); + + if (asset.isVideo) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + } + + context.pushRoute( + DriftMemoryRoute( + memories: memories, + memoryIndex: index, + ), + ); + }, + children: + memories.map((memory) => DriftMemoryCard(memory: memory)).toList(), + ), + ); + } +} + +class DriftMemoryCard extends ConsumerWidget { + const DriftMemoryCard({ + super.key, + required this.memory, + }); + + final DriftMemory memory; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final yearsAgo = DateTime.now().year - memory.data.year; + final title = 'years_ago'.t( + context: context, + args: { + 'years': yearsAgo.toString(), + }, + ); + return Center( + child: Stack( + children: [ + ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.2), + BlendMode.darken, + ), + child: Hero( + tag: 'memory-${memory.assets[0].id}', + child: SizedBox( + width: 205, + height: 200, + child: Thumbnail( + remoteId: memory.assets[0].id, + fit: BoxFit.cover, + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 114, + ), + child: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 15, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index e660f77767..f88c123e1a 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -26,6 +26,8 @@ class Scrubber extends ConsumerStatefulWidget { final double bottomPadding; + final double? monthSegmentSnappingOffset; + Scrubber({ super.key, Key? scrollThumbKey, @@ -33,6 +35,7 @@ class Scrubber extends ConsumerStatefulWidget { required this.timelineHeight, this.topPadding = 0, this.bottomPadding = 0, + this.monthSegmentSnappingOffset, required this.child, }) : assert(child.scrollDirection == Axis.vertical); @@ -296,7 +299,10 @@ class ScrubberState extends ConsumerState final viewportHeight = _scrollController.position.viewportDimension; final targetScrollOffset = layoutSegment.startOffset; - final centeredOffset = targetScrollOffset - (viewportHeight / 4) + 100; + final centeredOffset = targetScrollOffset - + (viewportHeight / 4) + + 100 + + (widget.monthSegmentSnappingOffset ?? 0.0); _scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent)); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 2ef01e5faf..04015aafe9 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -20,7 +20,10 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; class Timeline extends StatelessWidget { - const Timeline({super.key}); + const Timeline({super.key, this.topSliverWidget, this.topSliverWidgetHeight}); + + final Widget? topSliverWidget; + final double? topSliverWidgetHeight; @override Widget build(BuildContext context) { @@ -38,7 +41,10 @@ class Timeline extends StatelessWidget { ), ), ], - child: const _SliverTimeline(), + child: _SliverTimeline( + topSliverWidget: topSliverWidget, + topSliverWidgetHeight: topSliverWidgetHeight, + ), ), ), ); @@ -46,7 +52,10 @@ class Timeline extends StatelessWidget { } class _SliverTimeline extends ConsumerStatefulWidget { - const _SliverTimeline(); + const _SliverTimeline({this.topSliverWidget, this.topSliverWidgetHeight}); + + final Widget? topSliverWidget; + final double? topSliverWidgetHeight; @override ConsumerState createState() => _SliverTimelineState(); @@ -91,6 +100,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { timelineHeight: maxHeight, topPadding: totalAppBarHeight + 10, bottomPadding: context.padding.bottom + scrubberBottomPadding, + monthSegmentSnappingOffset: widget.topSliverWidgetHeight, child: CustomScrollView( primary: true, cacheExtent: maxHeight * 2, @@ -100,6 +110,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { pinned: false, snap: false, ), + if (widget.topSliverWidget != null) widget.topSliverWidget!, _SliverSegmentedList( segments: segments, delegate: SliverChildBuilderDelegate( diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart new file mode 100644 index 0000000000..0e58943f55 --- /dev/null +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -0,0 +1,27 @@ +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/domain/services/memory.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'db.provider.dart'; + +final driftMemoryRepositoryProvider = Provider( + (ref) => DriftMemoryRepository(ref.watch(driftProvider)), +); + +final driftMemoryServiceProvider = Provider( + (ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)), +); + +final driftMemoryFutureProvider = + FutureProvider.autoDispose>((ref) async { + final user = ref.watch(currentUserProvider); + if (user == null) { + return []; + } + + final service = ref.watch(driftMemoryServiceProvider); + + return service.getMemoryLane(user.id); +}); diff --git a/mobile/lib/providers/memory.provider.dart b/mobile/lib/providers/memory.provider.dart index 37f84dca93..aed546002d 100644 --- a/mobile/lib/providers/memory.provider.dart +++ b/mobile/lib/providers/memory.provider.dart @@ -1,7 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/services/memory.service.dart'; final memoryFutureProvider = @@ -10,7 +8,3 @@ final memoryFutureProvider = return await service.getMemoryLane(); }); - -final driftMemoryProvider = Provider( - (ref) => DriftMemoryRepository(ref.watch(driftProvider)), -); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index dae2dcdbfb..56e1aa0f96 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -71,6 +72,7 @@ import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -83,6 +85,7 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/local_auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; + import 'package:maplibre_gl/maplibre_gl.dart'; part 'router.gr.dart'; @@ -385,6 +388,11 @@ class AppRouter extends RootStackRouter { ), ), ), + AutoRoute( + page: DriftMemoryRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 348cea656e..f8f37970f9 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -618,6 +618,58 @@ class DriftAlbumsRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftMemoryPage] +class DriftMemoryRoute extends PageRouteInfo { + DriftMemoryRoute({ + required List memories, + required int memoryIndex, + Key? key, + List? children, + }) : super( + DriftMemoryRoute.name, + args: DriftMemoryRouteArgs( + memories: memories, + memoryIndex: memoryIndex, + key: key, + ), + initialChildren: children, + ); + + static const String name = 'DriftMemoryRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return DriftMemoryPage( + memories: args.memories, + memoryIndex: args.memoryIndex, + key: args.key, + ); + }, + ); +} + +class DriftMemoryRouteArgs { + const DriftMemoryRouteArgs({ + required this.memories, + required this.memoryIndex, + this.key, + }); + + final List memories; + + final int memoryIndex; + + final Key? key; + + @override + String toString() { + return 'DriftMemoryRouteArgs{memories: $memories, memoryIndex: $memoryIndex, key: $key}'; + } +} + /// generated route for /// [EditImagePage] class EditImageRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/hooks/blurhash_hook.dart b/mobile/lib/utils/hooks/blurhash_hook.dart index 9231e2d972..62208c4cf5 100644 --- a/mobile/lib/utils/hooks/blurhash_hook.dart +++ b/mobile/lib/utils/hooks/blurhash_hook.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:thumbhash/thumbhash.dart' as thumbhash; @@ -15,3 +16,15 @@ ObjectRef useBlurHashRef(Asset? asset) { return useRef(thumbhash.rgbaToBmp(rbga)); } + +ObjectRef useDriftBlurHashRef(RemoteAsset? asset) { + if (asset?.thumbHash == null) { + return useRef(null); + } + + final rbga = thumbhash.thumbHashToRGBA( + base64Decode(asset!.thumbHash!), + ); + + return useRef(thumbhash.rgbaToBmp(rbga)); +}