From d15f67da5dfc9a641540878a05168d006935e218 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 27 Jul 2025 11:18:32 -0500 Subject: [PATCH] feat: scroll to top & view in timeline (#20274) * feat: scroll to top & view in timeline * use EventStream * refactor: event invocation and listerner * fix: correct parent routing --- mobile/lib/domain/models/timeline.model.dart | 10 ++++ .../repositories/log.repository.dart | 10 +++- mobile/lib/pages/common/tab_shell.page.dart | 5 +- .../asset_viewer/top_app_bar.widget.dart | 15 +++++ .../memory/memory_bottom_info.widget.dart | 33 ++++++----- .../widgets/timeline/timeline.widget.dart | 58 ++++++++++++++++++- mobile/lib/providers/routes.provider.dart | 1 + .../lib/routing/app_navigation_observer.dart | 7 ++- 8 files changed, 114 insertions(+), 25 deletions(-) diff --git a/mobile/lib/domain/models/timeline.model.dart b/mobile/lib/domain/models/timeline.model.dart index 98a37d619c..3751500f0f 100644 --- a/mobile/lib/domain/models/timeline.model.dart +++ b/mobile/lib/domain/models/timeline.model.dart @@ -45,3 +45,13 @@ class TimeBucket extends Bucket { class TimelineReloadEvent extends Event { const TimelineReloadEvent(); } + +class ScrollToTopEvent extends Event { + const ScrollToTopEvent(); +} + +class ScrollToDateEvent extends Event { + final DateTime date; + + const ScrollToDateEvent(this.date); +} diff --git a/mobile/lib/infrastructure/repositories/log.repository.dart b/mobile/lib/infrastructure/repositories/log.repository.dart index 7a909d90cb..eefe2b0ab0 100644 --- a/mobile/lib/infrastructure/repositories/log.repository.dart +++ b/mobile/lib/infrastructure/repositories/log.repository.dart @@ -19,9 +19,13 @@ class IsarLogRepository extends IsarDatabaseRepository { Future insert(LogMessage log) async { final logEntity = LoggerMessage.fromDto(log); - await transaction(() async { - await _db.loggerMessages.put(logEntity); - }); + + try { + await transaction(() => _db.loggerMessages.put(logEntity)); + } catch (e) { + return false; + } + return true; } diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 5129f6b159..3961a8b14b 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; @@ -155,7 +156,7 @@ class _TabShellPageState extends ConsumerState { void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { // On Photos page menu tapped if (router.activeIndex == 0 && index == 0) { - scrollToTopNotifierProvider.scrollToTop(); + EventStream.shared.emit(const ScrollToTopEvent()); } // On Search page tapped diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 3f48a83bcb..f1163ad2fd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; @@ -15,6 +16,7 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const ViewerTopAppBar({super.key}); @@ -30,6 +32,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isInLockedView = ref.watch(inLockedViewProvider); + final previousRouteName = ref.watch(previousRouteNameProvider); + final showViewInTimelineButton = previousRouteName != TabShellRoute.name && previousRouteName != null; + final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); int opacity = ref.watch( assetViewerProvider.select((state) => state.backgroundOpacity), @@ -50,6 +55,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const CastActionButton( menuItem: true, ), + if (showViewInTimelineButton) + IconButton( + onPressed: () async { + await context.maybePop(); + await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); + EventStream.shared.emit(ScrollToDateEvent(asset.createdAt)); + }, + icon: const Icon(Icons.image_search), + tooltip: 'view_in_timeline', + ), if (asset.hasRemote && isOwner && !asset.isFavorite) const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), if (asset.hasRemote && isOwner && asset.isFavorite) diff --git a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart index 79e6288a72..4d8bf2d014 100644 --- a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart @@ -4,8 +4,9 @@ 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'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/routing/router.dart'; class DriftMemoryBottomInfo extends StatelessWidget { final DriftMemory memory; @@ -44,18 +45,22 @@ class DriftMemoryBottomInfo extends StatelessWidget { ), ], ), - 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, + Tooltip( + message: 'view_in_timeline'.tr(), + child: MaterialButton( + minWidth: 0, + onPressed: () { + context.maybePop(); + context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); + EventStream.shared.emit(ScrollToDateEvent(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/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 2439fd100b..59cc018b28 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -97,21 +97,73 @@ class _SliverTimeline extends ConsumerStatefulWidget { class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final _scrollController = ScrollController(); - StreamSubscription? _reloadSubscription; + StreamSubscription? _eventSubscription; @override void initState() { super.initState(); - _reloadSubscription = EventStream.shared.listen((_) => setState(() {})); + _eventSubscription = EventStream.shared.listen(_onEvent); + } + + void _onEvent(Event event) { + switch (event) { + case ScrollToTopEvent(): + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + case ScrollToDateEvent scrollToDateEvent: + _scrollToDate(scrollToDateEvent.date); + case TimelineReloadEvent(): + setState(() {}); + default: + break; + } } @override void dispose() { _scrollController.dispose(); - _reloadSubscription?.cancel(); + _eventSubscription?.cancel(); super.dispose(); } + void _scrollToDate(DateTime date) { + final asyncSegments = ref.read(timelineSegmentProvider); + asyncSegments.whenData((segments) { + // Find the segment that contains assets from the target date + final targetSegment = segments.firstWhereOrNull((segment) { + if (segment.bucket is TimeBucket) { + final segmentDate = (segment.bucket as TimeBucket).date; + // Check if the segment date matches the target date (year, month, day) + return segmentDate.year == date.year && segmentDate.month == date.month && segmentDate.day == date.day; + } + return false; + }); + + // If exact date not found, try to find the closest month + final fallbackSegment = targetSegment ?? + segments.firstWhereOrNull((segment) { + if (segment.bucket is TimeBucket) { + final segmentDate = (segment.bucket as TimeBucket).date; + return segmentDate.year == date.year && segmentDate.month == date.month; + } + return false; + }); + + if (fallbackSegment != null) { + // Scroll to the segment with a small offset to show the header + final targetOffset = fallbackSegment.startOffset - 50; + _scrollController.animateTo( + targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } + }); + } + @override Widget build(BuildContext _) { final asyncSegments = ref.watch(timelineSegmentProvider); diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart index 74d86f4767..52adabe233 100644 --- a/mobile/lib/providers/routes.provider.dart +++ b/mobile/lib/providers/routes.provider.dart @@ -2,3 +2,4 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; final inLockedViewProvider = StateProvider((ref) => false); final currentRouteNameProvider = StateProvider((ref) => null); +final previousRouteNameProvider = StateProvider((ref) => null); diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index 7e5d73cae8..12766cef66 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -26,9 +26,10 @@ class AppNavigationObserver extends AutoRouterObserver { void didPush(Route route, Route? previousRoute) { _handleLockedViewState(route, previousRoute); _handleDriftLockedFolderState(route, previousRoute); - Future( - () => ref.read(currentRouteNameProvider.notifier).state = route.settings.name, - ); + Future(() { + ref.read(currentRouteNameProvider.notifier).state = route.settings.name; + ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name; + }); } _handleLockedViewState(Route route, Route? previousRoute) {