mirror of
https://github.com/immich-app/immich.git
synced 2025-07-08 18:54:18 -04:00
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:
parent
ebcf133bea
commit
9ca31abae9
@ -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);
|
||||
|
@ -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]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
144
mobile/lib/providers/timeline/multiselect.provider.dart
Normal file
144
mobile/lib/providers/timeline/multiselect.provider.dart
Normal 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],
|
||||
);
|
Loading…
x
Reference in New Issue
Block a user