1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-07 23:03:36 +02:00

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
This commit is contained in:
Alex
2025-07-07 23:41:37 -05:00
committed by GitHub
parent dd94ad17aa
commit 87dd09d103
12 changed files with 471 additions and 85 deletions

View File

@ -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<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(
lockedSelectionAssets: assets.toSet(),
),
);
DLog.log(
"Selected ${selectedAssets?.length ?? 0} assets",
);
return Future.value();
},
),
_Feature(
name: 'Sync Local',
icon: Icons.photo_album_rounded,

View File

@ -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<BaseAsset> 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(),
);
}
}

View File

@ -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,

View File

@ -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,
),
),
);
}

View File

@ -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(),
),
],
],
),
);