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
This commit is contained in:
Alex 2025-06-24 02:05:25 -05:00 committed by GitHub
parent ebcf133bea
commit 9ca31abae9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 416 additions and 61 deletions

View File

@ -1,13 +1,18 @@
import 'package:flutter/material.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/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/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( const ThumbnailTile(
this.asset, { this.asset, {
this.size = const Size.square(256), this.size = const Size.square(256),
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.showStorageIndicator = true, this.showStorageIndicator = true,
this.canDeselect = true,
super.key, super.key,
}); });
@ -16,46 +21,128 @@ class ThumbnailTile extends StatelessWidget {
final BoxFit fit; final BoxFit fit;
final bool showStorageIndicator; final bool showStorageIndicator;
/// If we are allowed to deselect this image
final bool canDeselect;
@override @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( return Stack(
children: [ children: [
Positioned.fill(child: Thumbnail(asset: asset, fit: fit, size: size)), AnimatedContainer(
if (asset.isVideo) duration: Durations.short4,
Align( curve: Curves.decelerate,
alignment: Alignment.topRight, decoration: BoxDecoration(
child: Padding( color: isSelected
padding: const EdgeInsets.only(right: 10.0, top: 6.0), ? (canDeselect ? assetContainerColor : Colors.grey)
child: _VideoIndicator(asset.durationInSeconds ?? 0), : 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( if (isSelected)
alignment: Alignment.bottomRight, Padding(
child: Padding( padding: const EdgeInsets.all(3.0),
padding: const EdgeInsets.only(right: 10.0, bottom: 6.0), child: Align(
child: _TileOverlayIcon( alignment: Alignment.topLeft,
switch (asset.storage) { child: _SelectionIndicator(
AssetState.local => Icons.cloud_off_outlined, isSelected: isSelected,
AssetState.remote => Icons.cloud_outlined, color: assetContainerColor,
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),
),
),
], ],
); );
} }
} }
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 { class _VideoIndicator extends StatelessWidget {
final int durationInSeconds; final int durationInSeconds;
const _VideoIndicator(this.durationInSeconds); const _VideoIndicator(this.durationInSeconds);

View File

@ -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.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.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/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/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class FixedSegment extends Segment { class FixedSegment extends Segment {
final double tileHeight; final double tileHeight;
@ -60,21 +62,39 @@ class FixedSegment extends Segment {
return gridIndex + firstRowBelow - 1; 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 @override
Widget builder(BuildContext context, int index) { 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) { if (index == firstIndex) {
return TimelineHeader( return TimelineHeader(
bucket: bucket, bucket: bucket,
header: header, header: header,
height: headerExtent, 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); return _buildRow(firstAssetIndex + assetIndex, numberOfAssets);
} }
@ -97,7 +117,12 @@ class FixedSegment extends Segment {
// Bucket is already loaded, show the assets // Bucket is already loaded, show the assets
if (timelineService.hasRange(assetIndex, count)) { if (timelineService.hasRange(assetIndex, count)) {
final assets = timelineService.getAssets(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 // 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<BaseAsset> assets) => Widget _buildAssetRow(
BuildContext context,
List<BaseAsset> assets, {
required void Function(BaseAsset) onTap,
required void Function(BaseAsset) onLongPress,
}) =>
FixedTimelineRow( FixedTimelineRow(
dimension: tileHeight, dimension: tileHeight,
spacing: spacing, spacing: spacing,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
children: List.generate( children: List.generate(
assets.length, assets.length,
(i) => RepaintBoundary(child: ThumbnailTile(assets[i])), (i) => RepaintBoundary(
child: GestureDetector(
onTap: () => onTap(assets[i]),
onLongPress: () => onLongPress(assets[i]),
child: ThumbnailTile(assets[i]),
),
),
), ),
); );
} }

View File

@ -1,18 +1,25 @@
import 'package:easy_localization/easy_localization.dart'; 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/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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 Bucket bucket;
final HeaderType header; final HeaderType header;
final double height; final double height;
final int assetOffset;
const TimelineHeader({ const TimelineHeader({
super.key, super.key,
required this.bucket, required this.bucket,
required this.header, required this.header,
required this.height, required this.height,
required this.assetOffset,
}); });
String _formatMonth(BuildContext context, DateTime date) { String _formatMonth(BuildContext context, DateTime date) {
@ -28,33 +35,109 @@ class TimelineHeader extends StatelessWidget {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
if (bucket is! TimeBucket || header == HeaderType.none) { if (bucket is! TimeBucket || header == HeaderType.none) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final date = (bucket as TimeBucket).date; final date = (bucket as TimeBucket).date;
return Container(
padding: const EdgeInsets.only(left: 10, top: 30, bottom: 10), List<BaseAsset> bucketAssets;
height: height, try {
child: Column( bucketAssets = ref
crossAxisAlignment: CrossAxisAlignment.start, .watch(timelineServiceProvider)
mainAxisAlignment: MainAxisAlignment.spaceAround, .getAssets(assetOffset, bucket.assetCount);
children: [ } catch (e) {
if (header == HeaderType.month || header == HeaderType.monthAndDay) bucketAssets = <BaseAsset>[];
Text( }
_formatMonth(context, date),
style: context.textTheme.labelLarge final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets));
?.copyWith(fontSize: 24, fontWeight: FontWeight.w500), final isMonthHeader =
), header == HeaderType.month || header == HeaderType.monthAndDay;
if (header == HeaderType.day || header == HeaderType.monthAndDay) final isDayHeader =
Text( header == HeaderType.day || header == HeaderType.monthAndDay;
_formatDay(context, date),
style: context.textTheme.labelLarge return Padding(
?.copyWith(fontWeight: FontWeight.w500), 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,
),
);
}
}

View File

@ -22,7 +22,7 @@ abstract class SegmentBuilder {
case HeaderType.day: case HeaderType.day:
return kTimelineHeaderExtent * 0.90; return kTimelineHeaderExtent * 0.90;
case HeaderType.monthAndDay: case HeaderType.monthAndDay:
return kTimelineHeaderExtent * 1.5; return kTimelineHeaderExtent * 1.6;
case HeaderType.none: case HeaderType.none:
return 0.0; return 0.0;
} }

View File

@ -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, MultiSelectState>(
MultiSelectNotifier.new,
dependencies: [timelineServiceProvider],
);
class MultiSelectState {
final Set<BaseAsset> selectedAssets;
MultiSelectState({
required this.selectedAssets,
});
bool get isEnabled => selectedAssets.isNotEmpty;
MultiSelectState copyWith({
Set<BaseAsset>? 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<MultiSelectState> {
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<BaseAsset> 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<bool, List<BaseAsset>>(
(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],
);