refactor: actions provider (#19651)

* refactor: actions provider

* chore: rename error and stack

* remove empty checks

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong 2025-07-01 08:10:25 +05:30 committed by GitHub
parent 5011636d95
commit 21f500191a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 159 additions and 140 deletions

View File

@ -64,7 +64,7 @@ class TimelineService {
}) : _assetSource = assetSource, }) : _assetSource = assetSource,
_bucketSource = bucketSource { _bucketSource = bucketSource {
_bucketSubscription = _bucketSubscription =
_bucketSource().listen((_) => unawaited(_reloadBucket())); _bucketSource().listen((_) => unawaited(reloadBucket()));
} }
final AsyncMutex _mutex = AsyncMutex(); final AsyncMutex _mutex = AsyncMutex();
@ -74,7 +74,7 @@ class TimelineService {
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource; Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
Future<void> _reloadBucket() => _mutex.run(() async { Future<void> reloadBucket() => _mutex.run(() async {
_buffer = await _assetSource(_bufferOffset, _buffer.length); _buffer = await _assetSource(_bufferOffset, _buffer.length);
}); });

View File

@ -2,12 +2,11 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class ArchiveActionButton extends ConsumerWidget { class ArchiveActionButton extends ConsumerWidget {
@ -15,45 +14,28 @@ class ArchiveActionButton extends ConsumerWidget {
const ArchiveActionButton({super.key, required this.source}); const ArchiveActionButton({super.key, required this.source});
onAction(BuildContext context, WidgetRef ref) { void _onTap(BuildContext context, WidgetRef ref) async {
switch (source) { if (!context.mounted) {
case ActionSource.timeline:
timelineAction(context, ref);
case ActionSource.viewer:
viewerAction(ref);
}
}
void timelineAction(BuildContext context, WidgetRef ref) {
final user = ref.read(currentUserProvider);
if (user == null) {
return; return;
} }
final ids = ref final result = await ref.read(actionProvider.notifier).archive(source);
.read(multiSelectProvider.select((value) => value.selectedAssets)) await ref.read(timelineServiceProvider).reloadBucket();
.whereType<RemoteAsset>()
.where((asset) => asset.ownerId == user.id)
.map((asset) => asset.id)
.toList();
if (ids.isEmpty) {
return;
}
ref.read(actionProvider.notifier).archive(ids);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final toastMessage = 'archive_action_prompt'.t( final successMessage = 'archive_action_prompt'.t(
context: context, context: context,
args: {'count': ids.length.toString()}, args: {'count': result.count.toString()},
); );
if (context.mounted) { if (context.mounted) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: toastMessage, msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
); );
} }
} }
@ -67,7 +49,7 @@ class ArchiveActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.archive_outlined, iconData: Icons.archive_outlined,
label: "archive".t(context: context), label: "archive".t(context: context),
onPressed: () => onAction(context, ref), onPressed: () => _onTap(context, ref),
); );
} }
} }

View File

@ -2,12 +2,11 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteActionButton extends ConsumerWidget { class FavoriteActionButton extends ConsumerWidget {
@ -15,45 +14,28 @@ class FavoriteActionButton extends ConsumerWidget {
const FavoriteActionButton({super.key, required this.source}); const FavoriteActionButton({super.key, required this.source});
onAction(BuildContext context, WidgetRef ref) { void _onTap(BuildContext context, WidgetRef ref) async {
switch (source) { if (!context.mounted) {
case ActionSource.timeline:
timelineAction(context, ref);
case ActionSource.viewer:
viewerAction(ref);
}
}
void timelineAction(BuildContext context, WidgetRef ref) {
final user = ref.read(currentUserProvider);
if (user == null) {
return; return;
} }
final ids = ref final result = await ref.read(actionProvider.notifier).favorite(source);
.read(multiSelectProvider.select((value) => value.selectedAssets)) await ref.read(timelineServiceProvider).reloadBucket();
.whereType<RemoteAsset>()
.where((asset) => asset.ownerId == user.id)
.map((asset) => asset.id)
.toList();
if (ids.isEmpty) {
return;
}
ref.read(actionProvider.notifier).favorite(ids);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final toastMessage = 'favorite_action_prompt'.t( final successMessage = 'favorite_action_prompt'.t(
context: context, context: context,
args: {'count': ids.length.toString()}, args: {'count': result.count.toString()},
); );
if (context.mounted) { if (context.mounted) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: toastMessage, msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
); );
} }
} }
@ -67,7 +49,7 @@ class FavoriteActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.favorite_border_rounded, iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context), label: "favorite".t(context: context),
onPressed: () => onAction(context, ref), onPressed: () => _onTap(context, ref),
); );
} }
} }

View File

@ -30,8 +30,11 @@ class ThumbnailTile extends ConsumerWidget {
? context.primaryColor.darken(amount: 0.6) ? context.primaryColor.darken(amount: 0.6)
: context.primaryColor.lighten(amount: 0.8); : context.primaryColor.lighten(amount: 0.8);
final multiselect = ref.watch(multiSelectProvider); final isSelected = ref.watch(
final isSelected = multiselect.selectedAssets.contains(asset); multiSelectProvider.select(
(multiselect) => multiselect.selectedAssets.contains(asset),
),
);
return Stack( return Stack(
children: [ children: [

View File

@ -407,7 +407,7 @@ class _MultiSelectStatusButton extends ConsumerWidget {
final selectCount = final selectCount =
ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length)); ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
return ElevatedButton.icon( return ElevatedButton.icon(
onPressed: () => ref.read(multiSelectProvider.notifier).clearSelection(), onPressed: () => ref.read(multiSelectProvider.notifier).reset(),
icon: Icon( icon: Icon(
Icons.close_rounded, Icons.close_rounded,
color: context.colorScheme.onPrimary, color: context.colorScheme.onPrimary,

View File

@ -1,14 +1,36 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/action.service.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
final actionProvider = NotifierProvider<ActionNotifier, void>( final actionProvider = NotifierProvider<ActionNotifier, void>(
ActionNotifier.new, ActionNotifier.new,
dependencies: [ dependencies: [
actionServiceProvider, actionServiceProvider,
timelineServiceProvider,
multiselectProvider,
], ],
); );
class ActionResult {
final int count;
final bool success;
final String? error;
const ActionResult({required this.count, required this.success, this.error});
@override
String toString() =>
'ActionResult(count: $count, success: $success, error: $error)';
}
class ActionNotifier extends Notifier<void> { class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier');
late final ActionService _service; late final ActionService _service;
ActionNotifier() : super(); ActionNotifier() : super();
@ -18,19 +40,89 @@ class ActionNotifier extends Notifier<void> {
_service = ref.watch(actionServiceProvider); _service = ref.watch(actionServiceProvider);
} }
Future<void> favorite(List<String> ids) async { List<String> _getIdsForSource<T extends BaseAsset>(ActionSource source) {
await _service.favorite(ids); final currentUser = ref.read(currentUserProvider);
if (T is RemoteAsset && currentUser == null) {
return [];
}
final Set<BaseAsset> assets = switch (source) {
ActionSource.timeline =>
ref.read(multiSelectProvider.select((s) => s.selectedAssets)),
ActionSource.viewer => {},
};
return switch (T) {
const (RemoteAsset) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == currentUser!.id,
)
.cast<RemoteAsset>()
.map((asset) => asset.id)
.toList(),
const (LocalAsset) =>
assets.whereType<LocalAsset>().map((asset) => asset.id).toList(),
_ => [],
};
} }
Future<void> unFavorite(List<String> ids) async { Future<ActionResult> favorite(ActionSource source) async {
await _service.unFavorite(ids); final ids = _getIdsForSource<RemoteAsset>(source);
try {
await _service.favorite(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to favorite assets', error, stack);
return ActionResult(
count: ids.length,
success: false,
error: error.toString(),
);
}
} }
Future<void> archive(List<String> ids) async { Future<ActionResult> unFavorite(ActionSource source) async {
await _service.archive(ids); final ids = _getIdsForSource<RemoteAsset>(source);
try {
await _service.unFavorite(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unfavorite assets', error, stack);
return ActionResult(
count: ids.length,
success: false,
error: error.toString(),
);
}
} }
Future<void> unArchive(List<String> ids) async { Future<ActionResult> archive(ActionSource source) async {
await _service.unArchive(ids); final ids = _getIdsForSource<RemoteAsset>(source);
try {
await _service.archive(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to archive assets', error, stack);
return ActionResult(
count: ids.length,
success: false,
error: error.toString(),
);
}
}
Future<ActionResult> unArchive(ActionSource source) async {
final ids = _getIdsForSource<RemoteAsset>(source);
try {
await _service.unArchive(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unarchive assets', error, stack);
return ActionResult(
count: ids.length,
success: false,
error: error.toString(),
);
}
} }
} }

View File

@ -1,6 +1,5 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@ -13,12 +12,8 @@ final multiSelectProvider =
class MultiSelectState { class MultiSelectState {
final Set<BaseAsset> selectedAssets; final Set<BaseAsset> selectedAssets;
final int lastUpdatedTime;
const MultiSelectState({ const MultiSelectState({required this.selectedAssets});
required this.selectedAssets,
required this.lastUpdatedTime,
});
bool get isEnabled => selectedAssets.isNotEmpty; bool get isEnabled => selectedAssets.isNotEmpty;
bool get hasRemote => selectedAssets.any( bool get hasRemote => selectedAssets.any(
@ -30,31 +25,25 @@ class MultiSelectState {
(asset) => asset.storage == AssetState.local, (asset) => asset.storage == AssetState.local,
); );
MultiSelectState copyWith({ MultiSelectState copyWith({Set<BaseAsset>? selectedAssets}) {
Set<BaseAsset>? selectedAssets,
int? lastUpdatedTime,
}) {
return MultiSelectState( return MultiSelectState(
selectedAssets: selectedAssets ?? this.selectedAssets, selectedAssets: selectedAssets ?? this.selectedAssets,
lastUpdatedTime: lastUpdatedTime ?? this.lastUpdatedTime,
); );
} }
@override @override
String toString() => String toString() => 'MultiSelectState(selectedAssets: $selectedAssets)';
'MultiSelectState(selectedAssets: $selectedAssets, lastUpdatedTime: $lastUpdatedTime)';
@override @override
bool operator ==(covariant MultiSelectState other) { bool operator ==(covariant MultiSelectState other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals; final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.selectedAssets, selectedAssets) && return listEquals(other.selectedAssets, selectedAssets);
other.lastUpdatedTime == lastUpdatedTime;
} }
@override @override
int get hashCode => selectedAssets.hashCode ^ lastUpdatedTime.hashCode; int get hashCode => selectedAssets.hashCode;
} }
class MultiSelectNotifier extends Notifier<MultiSelectState> { class MultiSelectNotifier extends Notifier<MultiSelectState> {
@ -64,10 +53,7 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
MultiSelectState build() { MultiSelectState build() {
_timelineService = ref.read(timelineServiceProvider); _timelineService = ref.read(timelineServiceProvider);
return const MultiSelectState( return const MultiSelectState(selectedAssets: {});
selectedAssets: {},
lastUpdatedTime: 0,
);
} }
void selectAsset(BaseAsset asset) { void selectAsset(BaseAsset asset) {
@ -98,17 +84,8 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
} }
} }
void clearSelection() {
state = state.copyWith(
selectedAssets: {},
);
}
void reset() { void reset() {
state = MultiSelectState( state = const MultiSelectState(selectedAssets: {});
selectedAssets: {},
lastUpdatedTime: DateTime.now().millisecondsSinceEpoch,
);
} }
/// Bucket bulk operations /// Bucket bulk operations

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.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/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
@ -19,50 +18,34 @@ class ActionService {
const ActionService(this._assetApiRepository, this._remoteAssetRepository); const ActionService(this._assetApiRepository, this._remoteAssetRepository);
Future<void> favorite(List<String> remoteIds) async { Future<void> favorite(List<String> remoteIds) async {
try { await _assetApiRepository.updateFavorite(remoteIds, true);
await _assetApiRepository.updateFavorite(remoteIds, true); await _remoteAssetRepository.updateFavorite(remoteIds, true);
await _remoteAssetRepository.updateFavorite(remoteIds, true);
} catch (e) {
debugPrint('Error favoriting assets: $e');
}
} }
Future<void> unFavorite(List<String> remoteIds) async { Future<void> unFavorite(List<String> remoteIds) async {
try { await _assetApiRepository.updateFavorite(remoteIds, false);
await _assetApiRepository.updateFavorite(remoteIds, false); await _remoteAssetRepository.updateFavorite(remoteIds, false);
await _remoteAssetRepository.updateFavorite(remoteIds, false);
} catch (e) {
debugPrint('Error unfavoriting assets: $e');
}
} }
Future<void> archive(List<String> remoteIds) async { Future<void> archive(List<String> remoteIds) async {
try { await _assetApiRepository.updateVisibility(
await _assetApiRepository.updateVisibility( remoteIds,
remoteIds, AssetVisibilityEnum.archive,
AssetVisibilityEnum.archive, );
); await _remoteAssetRepository.updateVisibility(
await _remoteAssetRepository.updateVisibility( remoteIds,
remoteIds, AssetVisibility.archive,
AssetVisibility.archive, );
);
} catch (e) {
debugPrint('Error archive assets: $e');
}
} }
Future<void> unArchive(List<String> remoteIds) async { Future<void> unArchive(List<String> remoteIds) async {
try { await _assetApiRepository.updateVisibility(
await _assetApiRepository.updateVisibility( remoteIds,
remoteIds, AssetVisibilityEnum.timeline,
AssetVisibilityEnum.timeline, );
); await _remoteAssetRepository.updateVisibility(
await _remoteAssetRepository.updateVisibility( remoteIds,
remoteIds, AssetVisibility.timeline,
AssetVisibility.timeline, );
);
} catch (e) {
debugPrint('Error unarchive assets: $e');
}
} }
} }