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

@ -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<BaseAsset> assets) =>
Widget _buildAssetRow(
BuildContext context,
List<BaseAsset> 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]),
),
),
),
);
}

View file

@ -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<BaseAsset> bucketAssets;
try {
bucketAssets = ref
.watch(timelineServiceProvider)
.getAssets(assetOffset, bucket.assetCount);
} catch (e) {
bucketAssets = <BaseAsset>[];
}
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,
),
);
}
}

View file

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