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