From 9ca31abae9f1ba1212ee9bf9d7c92507e09932e7 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 24 Jun 2025 02:05:25 -0500 Subject: [PATCH] feat: new timeline multi-selection (#19443) * feat: new timeline multiselection * select all from bucket * wip * group multi-select * group multi-select * pr feedback * pr feedback * lint --- .../widgets/images/thumbnail_tile.widget.dart | 143 +++++++++++++---- .../widgets/timeline/fixed/segment.model.dart | 59 +++++-- .../widgets/timeline/header.widget.dart | 129 +++++++++++++--- .../widgets/timeline/segment_builder.dart | 2 +- .../timeline/multiselect.provider.dart | 144 ++++++++++++++++++ 5 files changed, 416 insertions(+), 61 deletions(-) create mode 100644 mobile/lib/providers/timeline/multiselect.provider.dart diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index eb64b2dcd3..7c2fbf4e21 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -1,13 +1,18 @@ 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/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -class ThumbnailTile extends StatelessWidget { +class ThumbnailTile extends ConsumerWidget { const ThumbnailTile( this.asset, { this.size = const Size.square(256), this.fit = BoxFit.cover, this.showStorageIndicator = true, + this.canDeselect = true, super.key, }); @@ -16,46 +21,128 @@ class ThumbnailTile extends StatelessWidget { final BoxFit fit; final bool showStorageIndicator; + /// If we are allowed to deselect this image + final bool canDeselect; + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final assetContainerColor = context.isDarkTheme + ? context.primaryColor.darken(amount: 0.6) + : context.primaryColor.lighten(amount: 0.8); + + final isSelected = ref + .watch(multiSelectProvider.select((state) => state.selectedAssets)) + .contains(asset); + return Stack( children: [ - Positioned.fill(child: Thumbnail(asset: asset, fit: fit, size: size)), - if (asset.isVideo) - Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.only(right: 10.0, top: 6.0), - child: _VideoIndicator(asset.durationInSeconds ?? 0), + 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(), + ), + child: ClipRRect( + borderRadius: isSelected + ? const BorderRadius.all(Radius.circular(15.0)) + : BorderRadius.zero, + child: Stack( + children: [ + Positioned.fill( + child: Thumbnail( + asset: asset, + fit: fit, + size: size, + ), + ), + if (asset.isVideo) + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(right: 10.0, top: 6.0), + child: _VideoIndicator(asset.durationInSeconds ?? 0), + ), + ), + if (showStorageIndicator) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: 10.0, bottom: 6.0), + child: _TileOverlayIcon( + switch (asset.storage) { + AssetState.local => Icons.cloud_off_outlined, + AssetState.remote => Icons.cloud_outlined, + AssetState.merged => Icons.cloud_done_outlined, + }, + ), + ), + ), + if (asset.isFavorite) + const Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: EdgeInsets.only(left: 10.0, bottom: 6.0), + child: _TileOverlayIcon(Icons.favorite_rounded), + ), + ), + ], ), ), - if (showStorageIndicator) - Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.only(right: 10.0, bottom: 6.0), - child: _TileOverlayIcon( - switch (asset.storage) { - AssetState.local => Icons.cloud_off_outlined, - AssetState.remote => Icons.cloud_outlined, - AssetState.merged => Icons.cloud_done_outlined, - }, + ), + if (isSelected) + Padding( + padding: const EdgeInsets.all(3.0), + child: Align( + alignment: Alignment.topLeft, + child: _SelectionIndicator( + isSelected: isSelected, + color: assetContainerColor, ), ), ), - if (asset.isFavorite) - const Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: EdgeInsets.only(left: 10.0, bottom: 6.0), - child: _TileOverlayIcon(Icons.favorite_rounded), - ), - ), ], ); } } +class _SelectionIndicator extends StatelessWidget { + final bool isSelected; + final Color? color; + const _SelectionIndicator({ + required this.isSelected, + this.color, + }); + + @override + Widget build(BuildContext context) { + if (isSelected) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + child: Icon( + Icons.check_circle_rounded, + color: context.primaryColor, + ), + ); + } else { + return const Icon( + Icons.circle_outlined, + color: Colors.white, + ); + } + } +} + class _VideoIndicator extends StatelessWidget { final int durationInSeconds; const _VideoIndicator(this.durationInSeconds); diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index bea754b3ff..fff5e68146 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -9,7 +9,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; class FixedSegment extends Segment { final double tileHeight; @@ -60,21 +62,39 @@ class FixedSegment extends Segment { return gridIndex + firstRowBelow - 1; } + void _handleOnTap(WidgetRef ref, BaseAsset asset) { + if (!ref.read(multiSelectProvider.select((s) => s.isEnabled))) { + return; + } + + ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + } + + void _handleOnLongPress(WidgetRef ref, BaseAsset asset) { + if (ref.read(multiSelectProvider.select((s) => s.isEnabled))) { + return; + } + + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + } + @override Widget builder(BuildContext context, int index) { + final rowIndexInSegment = index - (firstIndex + 1); + final assetIndex = rowIndexInSegment * columnCount; + final assetCount = bucket.assetCount; + final numberOfAssets = math.min(columnCount, assetCount - assetIndex); + if (index == firstIndex) { return TimelineHeader( bucket: bucket, header: header, height: headerExtent, + assetOffset: firstAssetIndex, ); } - final rowIndexInSegment = index - (firstIndex + 1); - final assetIndex = rowIndexInSegment * columnCount; - final assetCount = bucket.assetCount; - final numberOfAssets = math.min(columnCount, assetCount - assetIndex); - return _buildRow(firstAssetIndex + assetIndex, numberOfAssets); } @@ -97,7 +117,12 @@ class FixedSegment extends Segment { // Bucket is already loaded, show the assets if (timelineService.hasRange(assetIndex, count)) { final assets = timelineService.getAssets(assetIndex, count); - return _buildAssetRow(ctx, assets); + return _buildAssetRow( + ctx, + assets, + onTap: (asset) => _handleOnTap(ref, asset), + onLongPress: (asset) => _handleOnLongPress(ref, asset), + ); } // Bucket is not loaded, show placeholders and load the bucket @@ -113,20 +138,36 @@ class FixedSegment extends Segment { ); } - return _buildAssetRow(ctxx, snap.requireData); + return _buildAssetRow( + ctxx, + snap.requireData, + onTap: (asset) => _handleOnTap(ref, asset), + onLongPress: (asset) => _handleOnLongPress(ref, asset), + ); }, ); }, ); - Widget _buildAssetRow(BuildContext context, List assets) => + Widget _buildAssetRow( + BuildContext context, + List assets, { + required void Function(BaseAsset) onTap, + required void Function(BaseAsset) onLongPress, + }) => FixedTimelineRow( dimension: tileHeight, spacing: spacing, textDirection: Directionality.of(context), children: List.generate( assets.length, - (i) => RepaintBoundary(child: ThumbnailTile(assets[i])), + (i) => RepaintBoundary( + child: GestureDetector( + onTap: () => onTap(assets[i]), + onLongPress: () => onLongPress(assets[i]), + child: ThumbnailTile(assets[i]), + ), + ), ), ); } diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart index f5cce1dbbb..5c69f92a5a 100644 --- a/mobile/lib/presentation/widgets/timeline/header.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -1,18 +1,25 @@ import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/widgets.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/timeline.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -class TimelineHeader extends StatelessWidget { +class TimelineHeader extends ConsumerWidget { final Bucket bucket; final HeaderType header; final double height; + final int assetOffset; const TimelineHeader({ super.key, required this.bucket, required this.header, required this.height, + required this.assetOffset, }); String _formatMonth(BuildContext context, DateTime date) { @@ -28,33 +35,109 @@ class TimelineHeader extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { if (bucket is! TimeBucket || header == HeaderType.none) { return const SizedBox.shrink(); } final date = (bucket as TimeBucket).date; - return Container( - padding: const EdgeInsets.only(left: 10, top: 30, bottom: 10), - height: height, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - if (header == HeaderType.month || header == HeaderType.monthAndDay) - Text( - _formatMonth(context, date), - style: context.textTheme.labelLarge - ?.copyWith(fontSize: 24, fontWeight: FontWeight.w500), - ), - if (header == HeaderType.day || header == HeaderType.monthAndDay) - Text( - _formatDay(context, date), - style: context.textTheme.labelLarge - ?.copyWith(fontWeight: FontWeight.w500), - ), - ], + + List bucketAssets; + try { + bucketAssets = ref + .watch(timelineServiceProvider) + .getAssets(assetOffset, bucket.assetCount); + } catch (e) { + bucketAssets = []; + } + + final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets)); + final isMonthHeader = + header == HeaderType.month || header == HeaderType.monthAndDay; + final isDayHeader = + header == HeaderType.day || header == HeaderType.monthAndDay; + + return Padding( + padding: EdgeInsets.only( + top: isMonthHeader ? 8.0 : 0.0, + left: 12.0, + right: 12.0, + ), + child: SizedBox( + height: height, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (isMonthHeader) + Row( + children: [ + Text( + _formatMonth(context, date), + style: context.textTheme.labelLarge?.copyWith(fontSize: 24), + ), + const Spacer(), + if (header != HeaderType.monthAndDay) + _BulkSelectIconButton( + isAllSelected: isAllSelected, + onPressed: () => ref + .read(multiSelectProvider.notifier) + .toggleBucketSelection( + assetOffset, + bucket.assetCount, + ), + ), + ], + ), + if (isDayHeader) + Row( + children: [ + Text( + _formatDay(context, date), + style: context.textTheme.labelLarge?.copyWith( + fontSize: 15, + ), + ), + const Spacer(), + _BulkSelectIconButton( + isAllSelected: isAllSelected, + onPressed: () => ref + .read(multiSelectProvider.notifier) + .toggleBucketSelection(assetOffset, bucket.assetCount), + ), + ], + ), + ], + ), ), ); } } + +class _BulkSelectIconButton extends ConsumerWidget { + final bool isAllSelected; + final VoidCallback onPressed; + + const _BulkSelectIconButton({ + required this.isAllSelected, + required this.onPressed, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + onPressed: onPressed, + icon: isAllSelected + ? Icon( + Icons.check_circle_rounded, + size: 26, + color: context.primaryColor, + ) + : Icon( + Icons.check_circle_outline_rounded, + size: 26, + color: context.colorScheme.onSurfaceSecondary, + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart index 97031c623f..313292c5dd 100644 --- a/mobile/lib/presentation/widgets/timeline/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -22,7 +22,7 @@ abstract class SegmentBuilder { case HeaderType.day: return kTimelineHeaderExtent * 0.90; case HeaderType.monthAndDay: - return kTimelineHeaderExtent * 1.5; + return kTimelineHeaderExtent * 1.6; case HeaderType.none: return 0.0; } diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart new file mode 100644 index 0000000000..df9e999036 --- /dev/null +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -0,0 +1,144 @@ +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'; + +final multiSelectProvider = + NotifierProvider( + MultiSelectNotifier.new, + dependencies: [timelineServiceProvider], +); + +class MultiSelectState { + final Set selectedAssets; + + MultiSelectState({ + required this.selectedAssets, + }); + + bool get isEnabled => selectedAssets.isNotEmpty; + + MultiSelectState copyWith({ + Set? selectedAssets, + }) { + return MultiSelectState( + selectedAssets: selectedAssets ?? this.selectedAssets, + ); + } + + @override + String toString() => 'MultiSelectState(selectedAssets: $selectedAssets)'; + + @override + bool operator ==(covariant MultiSelectState other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.selectedAssets, selectedAssets); + } + + @override + int get hashCode => selectedAssets.hashCode; +} + +class MultiSelectNotifier extends Notifier { + late final TimelineService _timelineService; + + @override + MultiSelectState build() { + _timelineService = ref.read(timelineServiceProvider); + + return MultiSelectState( + selectedAssets: {}, + ); + } + + void selectAsset(BaseAsset asset) { + if (state.selectedAssets.contains(asset)) { + return; + } + + state = state.copyWith( + selectedAssets: {...state.selectedAssets, asset}, + ); + } + + void deselectAsset(BaseAsset asset) { + if (!state.selectedAssets.contains(asset)) { + return; + } + + state = state.copyWith( + selectedAssets: state.selectedAssets.where((a) => a != asset).toSet(), + ); + } + + void toggleAssetSelection(BaseAsset asset) { + if (state.selectedAssets.contains(asset)) { + deselectAsset(asset); + } else { + selectAsset(asset); + } + } + + /// Bucket bulk operations + void selectBucket(int offset, int bucketCount) async { + final assets = await _timelineService.loadAssets(offset, bucketCount); + final selectedAssets = state.selectedAssets.toSet(); + + selectedAssets.addAll(assets); + + state = state.copyWith( + selectedAssets: selectedAssets, + ); + } + + void deselectBucket(int offset, int bucketCount) async { + final assets = await _timelineService.loadAssets(offset, bucketCount); + final selectedAssets = state.selectedAssets.toSet(); + + selectedAssets.removeAll(assets); + + state = state.copyWith(selectedAssets: selectedAssets); + } + + void toggleBucketSelection(int offset, int bucketCount) async { + final assets = await _timelineService.loadAssets(offset, bucketCount); + toggleBucketSelectionByAssets(assets); + } + + void toggleBucketSelectionByAssets(List bucketAssets) { + if (bucketAssets.isEmpty) return; + + // Check if all assets in this bucket are currently selected + final allSelected = + bucketAssets.every((asset) => state.selectedAssets.contains(asset)); + + final selectedAssets = state.selectedAssets.toSet(); + + if (allSelected) { + // If all assets in this bucket are selected, deselect them + selectedAssets.removeAll(bucketAssets); + } else { + // If not all assets in this bucket are selected, select them all + selectedAssets.addAll(bucketAssets); + } + + state = state.copyWith(selectedAssets: selectedAssets); + } +} + +final bucketSelectionProvider = Provider.family>( + (ref, bucketAssets) { + final selectedAssets = + ref.watch(multiSelectProvider.select((s) => s.selectedAssets)); + + if (bucketAssets.isEmpty) return false; + + // Check if all assets in the bucket are selected + return bucketAssets.every((asset) => selectedAssets.contains(asset)); + }, + dependencies: [multiSelectProvider], +);