From 87dd09d103f2636158f74916a888e18c07c70a9d Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 7 Jul 2025 23:41:37 -0500 Subject: [PATCH] feat: selection mode timeline (#19734) * feat: new page * force multi-selection state * fix: provider scoping * Return selected assets * lint * lint * simplify provider scope and drop drilling * selection styling --- .../lib/domain/services/timeline.service.dart | 25 +++-- .../repositories/remote_asset.repository.dart | 16 ++++ .../repositories/timeline.repository.dart | 63 ++++++++++++- .../pages/dev/feat_in_development.page.dart | 28 ++++++ .../drift_asset_selection_timeline.page.dart | 44 +++++++++ .../widgets/images/thumbnail_tile.widget.dart | 62 ++++++++----- .../widgets/timeline/fixed/segment.model.dart | 38 ++++++-- .../widgets/timeline/timeline.widget.dart | 93 +++++++++++-------- .../timeline/multiselect.provider.dart | 54 +++++++++-- mobile/lib/routing/router.dart | 7 ++ mobile/lib/routing/router.gr.dart | 49 ++++++++++ .../common/selection_sliver_app_bar.dart | 77 +++++++++++++++ 12 files changed, 471 insertions(+), 85 deletions(-) create mode 100644 mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart create mode 100644 mobile/lib/widgets/common/selection_sliver_app_bar.dart diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 5cf8341a4d..eb332657ff 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -42,16 +42,29 @@ class TimelineFactory { TimelineService localAlbum({required String albumId}) => TimelineService( assetSource: (offset, count) => _timelineRepository - .getLocalBucketAssets(albumId, offset: offset, count: count), - bucketSource: () => - _timelineRepository.watchLocalBucket(albumId, groupBy: groupBy), + .getLocalAlbumBucketAssets(albumId, offset: offset, count: count), + bucketSource: () => _timelineRepository.watchLocalAlbumBucket( + albumId, + groupBy: groupBy, + ), ); TimelineService remoteAlbum({required String albumId}) => TimelineService( assetSource: (offset, count) => _timelineRepository - .getRemoteBucketAssets(albumId, offset: offset, count: count), - bucketSource: () => - _timelineRepository.watchRemoteBucket(albumId, groupBy: groupBy), + .getRemoteAlbumBucketAssets(albumId, offset: offset, count: count), + bucketSource: () => _timelineRepository.watchRemoteAlbumBucket( + albumId, + groupBy: groupBy, + ), + ); + + TimelineService remoteAssets(List timelineUsers) => TimelineService( + assetSource: (offset, count) => _timelineRepository + .getRemoteBucketAssets(timelineUsers, offset: offset, count: count), + bucketSource: () => _timelineRepository.watchRemoteBucket( + timelineUsers, + groupBy: GroupAssetsBy.month, + ), ); TimelineService favorite(String userId) => TimelineService( diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index fc341bc91e..95de34d1b4 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -13,6 +13,22 @@ class RemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; const RemoteAssetRepository(this._db) : super(_db); + /// For testing purposes + Future> getSome(String userId) { + final query = _db.remoteAssetEntity.select() + ..where( + (row) => + _db.remoteAssetEntity.ownerId.equals(userId) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline), + ) + ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) + ..limit(10); + + return query.map((row) => row.toDto()).get(); + } + Stream watchAsset(String id) { final query = _db.remoteAssetEntity .select() diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 10f3298716..a13109068c 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -104,7 +104,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { ).get(); } - Stream> watchLocalBucket( + Stream> watchLocalAlbumBucket( String albumId, { GroupAssetsBy groupBy = GroupAssetsBy.day, }) { @@ -137,7 +137,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { }).watch(); } - Future> getLocalBucketAssets( + Future> getLocalAlbumBucketAssets( String albumId, { required int offset, required int count, @@ -158,7 +158,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { .get(); } - Stream> watchRemoteBucket( + Stream> watchRemoteAlbumBucket( String albumId, { GroupAssetsBy groupBy = GroupAssetsBy.day, }) { @@ -192,7 +192,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { }).watch(); } - Future> getRemoteBucketAssets( + Future> getRemoteAlbumBucketAssets( String albumId, { required int offset, required int count, @@ -469,6 +469,61 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } + + Stream> watchRemoteBucket( + List userIds, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { + if (groupBy == GroupAssetsBy.none) { + return _db.remoteAssetEntity + .count( + where: (row) => + row.deletedAt.isNull() & + row.visibility.equalsValue(AssetVisibility.timeline) & + row.ownerId.isIn(userIds), + ) + .map(_generateBuckets) + .watchSingle(); + } + + final assetCountExp = _db.remoteAssetEntity.id.count(); + final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..where( + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline) & + _db.remoteAssetEntity.ownerId.isIn(userIds), + ) + ..groupBy([dateExp]) + ..orderBy([OrderingTerm.desc(dateExp)]); + + return query.map((row) { + final timeline = row.read(dateExp)!.dateFmt(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + } + + Future> getRemoteBucketAssets( + List userIds, { + required int offset, + required int count, + }) { + final query = _db.remoteAssetEntity.select() + ..where( + (row) => + row.deletedAt.isNull() & + row.visibility.equalsValue(AssetVisibility.timeline) & + row.ownerId.isIn(userIds), + ) + ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) + ..limit(count, offset: offset); + + return query.map((row) => row.toDto()).get(); + } } extension on Expression { diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index 2698eefccc..a4343b720f 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -5,15 +5,43 @@ import 'package:drift/drift.dart' hide Column; 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/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; final _features = [ + _Feature( + name: 'Selection Mode Timeline', + icon: Icons.developer_mode_rounded, + onTap: (ctx, ref) async { + final user = ref.watch(currentUserProvider); + if (user == null) { + return Future.value(); + } + + final assets = + await ref.read(remoteAssetRepositoryProvider).getSome(user.id); + + final selectedAssets = await ctx.pushRoute>( + DriftAssetSelectionTimelineRoute( + lockedSelectionAssets: assets.toSet(), + ), + ); + + DLog.log( + "Selected ${selectedAssets?.length ?? 0} assets", + ); + + return Future.value(); + }, + ), _Feature( name: 'Sync Local', icon: Icons.photo_album_rounded, diff --git a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart new file mode 100644 index 0000000000..b7cb033828 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart @@ -0,0 +1,44 @@ +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/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; + +@RoutePage() +class DriftAssetSelectionTimelinePage extends ConsumerWidget { + final Set lockedSelectionAssets; + const DriftAssetSelectionTimelinePage({ + super.key, + this.lockedSelectionAssets = const {}, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ProviderScope( + overrides: [ + multiSelectProvider.overrideWith( + () => MultiSelectNotifier( + MultiSelectState( + selectedAssets: {}, + lockedSelectionAssets: lockedSelectionAssets, + forceEnable: true, + ), + ), + ), + timelineServiceProvider.overrideWith( + (ref) { + final timelineUsers = + ref.watch(timelineUsersProvider).valueOrNull ?? []; + final timelineService = + ref.watch(timelineFactoryProvider).remoteAssets(timelineUsers); + ref.onDispose(timelineService.dispose); + return timelineService; + }, + ), + ], + child: const Timeline(), + ); + } +} diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 571d1c5412..9d7fae817e 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -12,7 +12,7 @@ class ThumbnailTile extends ConsumerWidget { this.size = const Size.square(256), this.fit = BoxFit.cover, this.showStorageIndicator = true, - this.canDeselect = true, + this.lockSelection = false, super.key, }); @@ -20,15 +20,13 @@ class ThumbnailTile extends ConsumerWidget { final Size size; final BoxFit fit; final bool showStorageIndicator; - - /// If we are allowed to deselect this image - final bool canDeselect; + final bool lockSelection; @override Widget build(BuildContext context, WidgetRef ref) { final assetContainerColor = context.isDarkTheme - ? context.primaryColor.darken(amount: 0.6) - : context.primaryColor.lighten(amount: 0.8); + ? context.primaryColor.darken(amount: 0.4) + : context.primaryColor.lighten(amount: 0.75); final isSelected = ref.watch( multiSelectProvider.select( @@ -36,24 +34,29 @@ class ThumbnailTile extends ConsumerWidget { ), ); + final borderStyle = lockSelection + ? BoxDecoration( + color: context.colorScheme.surfaceContainerHighest, + border: Border.all( + color: context.colorScheme.surfaceContainerHighest, + width: 6, + ), + ) + : isSelected + ? BoxDecoration( + color: assetContainerColor, + border: Border.all(color: assetContainerColor, width: 6), + ) + : const BoxDecoration(); + return Stack( children: [ AnimatedContainer( duration: Durations.short4, curve: Curves.decelerate, - decoration: BoxDecoration( - color: isSelected - ? (canDeselect ? assetContainerColor : Colors.grey) - : null, - border: isSelected - ? Border.all( - color: canDeselect ? assetContainerColor : Colors.grey, - width: 8, - ) - : const Border(), - ), + decoration: borderStyle, child: ClipRRect( - borderRadius: isSelected + borderRadius: isSelected || lockSelection ? const BorderRadius.all(Radius.circular(15.0)) : BorderRadius.zero, child: Stack( @@ -102,14 +105,17 @@ class ThumbnailTile extends ConsumerWidget { ), ), ), - if (isSelected) + if (isSelected || lockSelection) Padding( padding: const EdgeInsets.all(3.0), child: Align( alignment: Alignment.topLeft, child: _SelectionIndicator( isSelected: isSelected, - color: assetContainerColor, + isLocked: lockSelection, + color: lockSelection + ? context.colorScheme.surfaceContainerHighest + : assetContainerColor, ), ), ), @@ -120,15 +126,29 @@ class ThumbnailTile extends ConsumerWidget { class _SelectionIndicator extends StatelessWidget { final bool isSelected; + final bool isLocked; final Color? color; + const _SelectionIndicator({ required this.isSelected, + required this.isLocked, this.color, }); @override Widget build(BuildContext context) { - if (isSelected) { + if (isLocked) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + child: const Icon( + Icons.check_circle_rounded, + color: Colors.grey, + ), + ); + } else if (isSelected) { return Container( decoration: BoxDecoration( shape: BoxShape.circle, diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 3fbba803db..14b1a4616d 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -166,22 +166,22 @@ class _AssetTileWidget extends ConsumerWidget { BaseAsset asset, ) { final multiSelectState = ref.read(multiSelectProvider); - if (!multiSelectState.isEnabled) { + + if (multiSelectState.forceEnable || multiSelectState.isEnabled) { + ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + } else { ctx.pushRoute( AssetViewerRoute( initialIndex: assetIndex, timelineService: ref.read(timelineServiceProvider), ), ); - return; } - - ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); } void _handleOnLongPress(WidgetRef ref, BaseAsset asset) { final multiSelectState = ref.read(multiSelectProvider); - if (multiSelectState.isEnabled) { + if (multiSelectState.isEnabled || multiSelectState.forceEnable) { return; } @@ -189,13 +189,35 @@ class _AssetTileWidget extends ConsumerWidget { ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); } + bool _getLockSelectionStatus(WidgetRef ref) { + final lockSelectionAssets = ref.read( + multiSelectProvider.select( + (state) => state.lockedSelectionAssets, + ), + ); + + if (lockSelectionAssets.isEmpty) { + return false; + } + + return lockSelectionAssets.contains(asset); + } + @override Widget build(BuildContext context, WidgetRef ref) { + final lockSelection = _getLockSelectionStatus(ref); + return RepaintBoundary( child: GestureDetector( - onTap: () => _handleOnTap(context, ref, assetIndex, asset), - onLongPress: () => _handleOnLongPress(ref, asset), - child: ThumbnailTile(asset), + onTap: () => lockSelection + ? null + : _handleOnTap(context, ref, assetIndex, asset), + onLongPress: () => + lockSelection ? null : _handleOnLongPress(ref, asset), + child: ThumbnailTile( + asset, + lockSelection: lockSelection, + ), ), ); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 04015aafe9..9fb164e2dc 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -18,9 +18,14 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; +import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; class Timeline extends StatelessWidget { - const Timeline({super.key, this.topSliverWidget, this.topSliverWidgetHeight}); + const Timeline({ + super.key, + this.topSliverWidget, + this.topSliverWidgetHeight, + }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; @@ -52,7 +57,10 @@ class Timeline extends StatelessWidget { } class _SliverTimeline extends ConsumerStatefulWidget { - const _SliverTimeline({this.topSliverWidget, this.topSliverWidgetHeight}); + const _SliverTimeline({ + this.topSliverWidget, + this.topSliverWidgetHeight, + }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; @@ -84,6 +92,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final asyncSegments = ref.watch(timelineSegmentProvider); final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); + final isSelectionMode = ref.watch( + multiSelectProvider.select((s) => s.forceEnable), + ); + return asyncSegments.widgetWhen( onData: (segments) { final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; @@ -105,11 +117,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { primary: true, cacheExtent: maxHeight * 2, slivers: [ - const ImmichSliverAppBar( - floating: true, - pinned: false, - snap: false, - ), + if (isSelectionMode) + const SelectionSliverAppBar() + else + const ImmichSliverAppBar( + floating: true, + pinned: false, + snap: false, + ), if (widget.topSliverWidget != null) widget.topSliverWidget!, _SliverSegmentedList( segments: segments, @@ -134,40 +149,42 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { ], ), ), - Consumer( - builder: (_, consumerRef, child) { - final isMultiSelectEnabled = consumerRef.watch( - multiSelectProvider.select( - (s) => s.isEnabled, - ), - ); + if (!isSelectionMode) ...[ + Consumer( + builder: (_, consumerRef, child) { + final isMultiSelectEnabled = consumerRef.watch( + multiSelectProvider.select( + (s) => s.isEnabled, + ), + ); - if (isMultiSelectEnabled) { - return child!; - } - return const SizedBox.shrink(); - }, - child: const Positioned( - top: 60, - left: 25, - child: _MultiSelectStatusButton(), + if (isMultiSelectEnabled) { + return child!; + } + return const SizedBox.shrink(); + }, + child: const Positioned( + top: 60, + left: 25, + child: _MultiSelectStatusButton(), + ), ), - ), - Consumer( - builder: (_, consumerRef, child) { - final isMultiSelectEnabled = consumerRef.watch( - multiSelectProvider.select( - (s) => s.isEnabled, - ), - ); + Consumer( + builder: (_, consumerRef, child) { + final isMultiSelectEnabled = consumerRef.watch( + multiSelectProvider.select( + (s) => s.isEnabled, + ), + ); - if (isMultiSelectEnabled) { - return child!; - } - return const SizedBox.shrink(); - }, - child: const HomeBottomAppBar(), - ), + if (isMultiSelectEnabled) { + return child!; + } + return const SizedBox.shrink(); + }, + child: const HomeBottomAppBar(), + ), + ], ], ), ); diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 2b24a272bf..b1a926545d 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; + import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -12,8 +13,14 @@ final multiSelectProvider = class MultiSelectState { final Set selectedAssets; + final Set lockedSelectionAssets; + final bool forceEnable; - const MultiSelectState({required this.selectedAssets}); + const MultiSelectState({ + required this.selectedAssets, + required this.lockedSelectionAssets, + this.forceEnable = false, + }); bool get isEnabled => selectedAssets.isNotEmpty; bool get hasRemote => selectedAssets.any( @@ -25,33 +32,54 @@ class MultiSelectState { (asset) => asset.storage == AssetState.local, ); - MultiSelectState copyWith({Set? selectedAssets}) { + MultiSelectState copyWith({ + Set? selectedAssets, + Set? lockedSelectionAssets, + bool? forceEnable, + }) { return MultiSelectState( selectedAssets: selectedAssets ?? this.selectedAssets, + lockedSelectionAssets: + lockedSelectionAssets ?? this.lockedSelectionAssets, + forceEnable: forceEnable ?? this.forceEnable, ); } @override - String toString() => 'MultiSelectState(selectedAssets: $selectedAssets)'; + String toString() => + 'MultiSelectState(selectedAssets: $selectedAssets, lockedSelectionAssets: $lockedSelectionAssets, forceEnable: $forceEnable)'; @override bool operator ==(covariant MultiSelectState other) { if (identical(this, other)) return true; - final listEquals = const DeepCollectionEquality().equals; + final setEquals = const DeepCollectionEquality().equals; - return listEquals(other.selectedAssets, selectedAssets); + return setEquals(other.selectedAssets, selectedAssets) && + setEquals(other.lockedSelectionAssets, lockedSelectionAssets) && + other.forceEnable == forceEnable; } @override - int get hashCode => selectedAssets.hashCode; + int get hashCode => + selectedAssets.hashCode ^ + lockedSelectionAssets.hashCode ^ + forceEnable.hashCode; } class MultiSelectNotifier extends Notifier { + MultiSelectNotifier([this._defaultState]); + final MultiSelectState? _defaultState; + TimelineService get _timelineService => ref.read(timelineServiceProvider); @override MultiSelectState build() { - return const MultiSelectState(selectedAssets: {}); + return _defaultState ?? + const MultiSelectState( + selectedAssets: {}, + lockedSelectionAssets: {}, + forceEnable: false, + ); } void selectAsset(BaseAsset asset) { @@ -83,7 +111,11 @@ class MultiSelectNotifier extends Notifier { } void reset() { - state = const MultiSelectState(selectedAssets: {}); + state = const MultiSelectState( + selectedAssets: {}, + lockedSelectionAssets: {}, + forceEnable: false, + ); } /// Bucket bulk operations @@ -131,6 +163,12 @@ class MultiSelectNotifier extends Notifier { state = state.copyWith(selectedAssets: selectedAssets); } + + void setLockedSelectionAssets(Set assets) { + state = state.copyWith( + lockedSelectionAssets: assets, + ); + } } final bucketSelectionProvider = Provider.family>( diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index aa40ababfb..4bf287c08a 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -1,6 +1,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/asset/base_asset.model.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'; @@ -78,6 +79,7 @@ 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_library.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.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'; @@ -422,6 +424,11 @@ class AppRouter extends RootStackRouter { page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: DriftAssetSelectionTimelineRoute.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 08e4a44ecb..f752c2dc8a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -634,6 +634,55 @@ class DriftArchiveRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftAssetSelectionTimelinePage] +class DriftAssetSelectionTimelineRoute + extends PageRouteInfo { + DriftAssetSelectionTimelineRoute({ + Key? key, + Set lockedSelectionAssets = const {}, + List? children, + }) : super( + DriftAssetSelectionTimelineRoute.name, + args: DriftAssetSelectionTimelineRouteArgs( + key: key, + lockedSelectionAssets: lockedSelectionAssets, + ), + initialChildren: children, + ); + + static const String name = 'DriftAssetSelectionTimelineRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const DriftAssetSelectionTimelineRouteArgs(), + ); + return DriftAssetSelectionTimelinePage( + key: args.key, + lockedSelectionAssets: args.lockedSelectionAssets, + ); + }, + ); +} + +class DriftAssetSelectionTimelineRouteArgs { + const DriftAssetSelectionTimelineRouteArgs({ + this.key, + this.lockedSelectionAssets = const {}, + }); + + final Key? key; + + final Set lockedSelectionAssets; + + @override + String toString() { + return 'DriftAssetSelectionTimelineRouteArgs{key: $key, lockedSelectionAssets: $lockedSelectionAssets}'; + } +} + /// generated route for /// [DriftFavoritePage] class DriftFavoriteRoute extends PageRouteInfo { diff --git a/mobile/lib/widgets/common/selection_sliver_app_bar.dart b/mobile/lib/widgets/common/selection_sliver_app_bar.dart new file mode 100644 index 0000000000..2f06934bc0 --- /dev/null +++ b/mobile/lib/widgets/common/selection_sliver_app_bar.dart @@ -0,0 +1,77 @@ +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/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; + +class SelectionSliverAppBar extends ConsumerStatefulWidget { + const SelectionSliverAppBar({ + super.key, + }); + + @override + ConsumerState createState() => + _SelectionSliverAppBarState(); +} + +class _SelectionSliverAppBarState extends ConsumerState { + @override + Widget build(BuildContext context) { + final selection = ref.watch( + multiSelectProvider.select((s) => s.selectedAssets), + ); + + final toExclude = ref.watch( + multiSelectProvider.select((s) => s.lockedSelectionAssets), + ); + + final filteredAssets = selection.where((asset) { + return !toExclude.contains(asset); + }).toSet(); + + onDone(Set selected) { + ref.read(multiSelectProvider.notifier).reset(); + context.maybePop>(selected); + } + + return SliverAppBar( + floating: true, + pinned: true, + snap: false, + backgroundColor: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + ref.read(multiSelectProvider.notifier).reset(); + context.pop>(null); + }, + ), + centerTitle: true, + title: Text( + "Select {count}".t( + context: context, + args: { + 'count': filteredAssets.length.toString(), + }, + ), + ), + actions: [ + TextButton( + onPressed: () => onDone(filteredAssets), + child: Text( + 'done'.t(context: context), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.primary, + ), + ), + ), + ], + ); + } +}