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
This commit is contained in:
Alex 2025-07-27 11:18:32 -05:00 committed by GitHub
parent 6becf409da
commit d15f67da5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 114 additions and 25 deletions

View File

@ -45,3 +45,13 @@ class TimeBucket extends Bucket {
class TimelineReloadEvent extends Event { class TimelineReloadEvent extends Event {
const TimelineReloadEvent(); const TimelineReloadEvent();
} }
class ScrollToTopEvent extends Event {
const ScrollToTopEvent();
}
class ScrollToDateEvent extends Event {
final DateTime date;
const ScrollToDateEvent(this.date);
}

View File

@ -19,9 +19,13 @@ class IsarLogRepository extends IsarDatabaseRepository {
Future<bool> insert(LogMessage log) async { Future<bool> insert(LogMessage log) async {
final logEntity = LoggerMessage.fromDto(log); 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; return true;
} }

View File

@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.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/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@ -155,7 +156,7 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
// On Photos page menu tapped // On Photos page menu tapped
if (router.activeIndex == 0 && index == 0) { if (router.activeIndex == 0 && index == 0) {
scrollToTopNotifierProvider.scrollToTop(); EventStream.shared.emit(const ScrollToTopEvent());
} }
// On Search page tapped // On Search page tapped

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.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/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key}); const ViewerTopAppBar({super.key});
@ -30,6 +32,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider); 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)); final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch( int opacity = ref.watch(
assetViewerProvider.select((state) => state.backgroundOpacity), assetViewerProvider.select((state) => state.backgroundOpacity),
@ -50,6 +55,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const CastActionButton( const CastActionButton(
menuItem: true, 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) if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.hasRemote && isOwner && asset.isFavorite) if (asset.hasRemote && isOwner && asset.isFavorite)

View File

@ -4,8 +4,9 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/routing/router.dart';
class DriftMemoryBottomInfo extends StatelessWidget { class DriftMemoryBottomInfo extends StatelessWidget {
final DriftMemory memory; final DriftMemory memory;
@ -44,11 +45,14 @@ class DriftMemoryBottomInfo extends StatelessWidget {
), ),
], ],
), ),
MaterialButton( Tooltip(
message: 'view_in_timeline'.tr(),
child: MaterialButton(
minWidth: 0, minWidth: 0,
onPressed: () { onPressed: () {
context.maybePop(); context.maybePop();
scrollToDateNotifierProvider.scrollToDate(fileCreatedDate); context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate));
}, },
shape: const CircleBorder(), shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2), color: Colors.white.withValues(alpha: 0.2),
@ -58,6 +62,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
color: Colors.white, color: Colors.white,
), ),
), ),
),
]), ]),
); );
} }

View File

@ -97,21 +97,73 @@ class _SliverTimeline extends ConsumerStatefulWidget {
class _SliverTimelineState extends ConsumerState<_SliverTimeline> { class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
StreamSubscription? _reloadSubscription; StreamSubscription? _eventSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_reloadSubscription = EventStream.shared.listen<TimelineReloadEvent>((_) => 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 @override
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
_reloadSubscription?.cancel(); _eventSubscription?.cancel();
super.dispose(); 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 @override
Widget build(BuildContext _) { Widget build(BuildContext _) {
final asyncSegments = ref.watch(timelineSegmentProvider); final asyncSegments = ref.watch(timelineSegmentProvider);

View File

@ -2,3 +2,4 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
final inLockedViewProvider = StateProvider<bool>((ref) => false); final inLockedViewProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null); final currentRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteNameProvider = StateProvider<String?>((ref) => null);

View File

@ -26,9 +26,10 @@ class AppNavigationObserver extends AutoRouterObserver {
void didPush(Route route, Route? previousRoute) { void didPush(Route route, Route? previousRoute) {
_handleLockedViewState(route, previousRoute); _handleLockedViewState(route, previousRoute);
_handleDriftLockedFolderState(route, previousRoute); _handleDriftLockedFolderState(route, previousRoute);
Future( Future(() {
() => ref.read(currentRouteNameProvider.notifier).state = route.settings.name, ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
); ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
});
} }
_handleLockedViewState(Route route, Route? previousRoute) { _handleLockedViewState(Route route, Route? previousRoute) {