mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
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:
parent
6becf409da
commit
d15f67da5d
@ -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);
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user