refactor: mobile tag and download action

This commit is contained in:
shenlong-tanwen
2026-07-30 02:58:59 +05:30
parent 3c5aca6280
commit 71de8a21ab
15 changed files with 313 additions and 207 deletions
@@ -0,0 +1,49 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/utils/error_handler.dart';
final _stateProvider = Provider.family.autoDispose<List<RemoteAsset>?, ActionSource>((ref, source) {
final assets = ref.watch(assetsActionProvider(source));
final remote = assets.remote().toList(growable: false);
return remote.isEmpty ? null : remote;
});
class DownloadAction extends AssetActionBuilder {
const DownloadAction({required super.source});
@override
ActionItem? create(BuildContext context, WidgetRef ref) {
final assets = ref.watch(_stateProvider(source));
if (assets == null) {
return null;
}
return .new(icon: Icons.download, label: context.t.download, onAction: () => _download(ref, assets));
}
Future<void> _download(WidgetRef ref, List<RemoteAsset> assets) async {
final backgroundSync = ref.read(backgroundSyncProvider);
final downloads = ref.read(downloadRepositoryProvider);
try {
await downloads.downloadAllAssets(assets);
unawaited(
Future.delayed(const .new(seconds: 1), () async {
await backgroundSync.syncLocal();
await backgroundSync.hashAssets();
}),
);
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to download the assets");
}
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>((ref, source) {
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
if (!tagsEnabled) {
return null;
}
final assets = ref.watch(ownedAssetsActionProvider(source));
final assetIds = assets.map((asset) => asset.id).toList(growable: false);
return assetIds.isEmpty ? null : assetIds;
});
class TagAction extends AssetActionBuilder {
const TagAction({required super.source});
@override
ActionItem? create(BuildContext context, WidgetRef ref) {
final assetIds = ref.watch(_stateProvider(source));
if (assetIds == null) {
return null;
}
return .new(
icon: Icons.sell_outlined,
label: context.t.control_bottom_app_bar_add_tags,
onAction: () => _tag(context, ref, assetIds),
);
}
Future<void> _tag(BuildContext context, WidgetRef ref, List<String> assetIds) async {
final clearSelection = ref.read(clearSelectionProvider(source));
try {
final results = await showTagPickerModal(context: context);
if (results == null || !context.mounted) {
return;
}
final (selected, created) = results;
await tagAssets(context, ref, assetIds, selected: selected, created: created);
clearSelection();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to tag the assets");
}
}
}
@visibleForTesting
Future<void> tagAssets(
BuildContext context,
WidgetRef ref,
List<String> assetIds, {
required Set<String> selected,
required Set<String> created,
}) async {
final tagService = ref.read(tagServiceProvider);
final toastService = ref.read(toastServiceProvider);
final tagIds = {...selected};
if (created.isNotEmpty) {
final tags = await tagService.upsertTags(created.toList());
tagIds.addAll(tags.map((tag) => tag.id));
}
if (tagIds.isEmpty) {
return;
}
final count = await tagService.bulkTagAssets(assetIds, tagIds.toList());
ref.invalidate(tagProvider);
if (context.mounted) {
toastService.success(context.t.tagged_assets(count: count));
}
}
@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class BulkTagAssetsActionButton extends ConsumerWidget {
final ActionSource source;
const BulkTagAssetsActionButton({super.key, required this.source});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
final result = await ref.read(actionProvider.notifier).tagAssets(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: result.success
? 'tagged_assets'.t(context: context, args: {'count': result.count.toString()})
: 'errors.failed_to_tag_assets'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.sell_outlined,
label: "control_bottom_app_bar_add_tags".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,47 +0,0 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.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/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class DownloadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
if (!context.mounted) {
return;
}
try {
await ref.read(actionProvider.notifier).downloadAll(source);
Future.delayed(const Duration(seconds: 1), () async {
await backgroundSyncManager.syncLocal();
await backgroundSyncManager.hashAssets();
});
} finally {
ref.read(multiSelectProvider.notifier).reset();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final backgroundManager = ref.watch(backgroundSyncProvider);
return BaseActionButton(
iconData: Icons.download,
maxWidth: 95,
label: "download".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager),
);
}
}
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
@@ -13,7 +14,6 @@ import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -81,7 +81,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const ActionColumnButton(action: ShareLinkAction(source: .timeline)),
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DownloadAction(source: .timeline)),
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const ActionColumnButton(action: EditDateTimeAction(source: .timeline)),
const ActionColumnButton(action: EditLocationAction(source: .timeline)),
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
@@ -14,7 +14,6 @@ import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -78,7 +77,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ActionColumnButton(action: ShareLinkAction(source: .timeline)),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DownloadAction(source: .timeline)),
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const ActionColumnButton(action: EditDateTimeAction(source: .timeline)),
const ActionColumnButton(action: EditLocationAction(source: .timeline)),
@@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/tag.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
@@ -13,13 +15,10 @@ import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -48,9 +47,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
@@ -86,10 +82,10 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ActionColumnButton(action: ShareAction(source: .timeline)),
if (multiselect.hasRemote) ...[
const ActionColumnButton(action: ShareLinkAction(source: .timeline)),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DownloadAction(source: .timeline)),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: TagAction(source: .timeline)),
const ActionColumnButton(action: EditDateTimeAction(source: .timeline)),
const ActionColumnButton(action: EditLocationAction(source: .timeline)),
const ActionColumnButton(action: LockAction(source: .timeline)),
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
class LockedFolderBottomSheet extends ConsumerWidget {
@@ -19,7 +18,7 @@ class LockedFolderBottomSheet extends ConsumerWidget {
shouldCloseOnMinExtent: false,
actions: [
ActionColumnButton(action: ShareAction(source: .timeline)),
DownloadActionButton(source: ActionSource.timeline),
ActionColumnButton(action: DownloadAction(source: .timeline)),
ActionColumnButton(action: DeleteAction(source: .timeline)),
ActionColumnButton(action: LockAction(source: .timeline)),
],
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -66,7 +66,7 @@ class _PartnerDetailBottomSheetState extends ConsumerState<PartnerDetailBottomSh
shouldCloseOnMinExtent: false,
actions: const [
ActionColumnButton(action: ShareAction(source: .timeline)),
DownloadActionButton(source: ActionSource.timeline),
ActionColumnButton(action: DownloadAction(source: .timeline)),
],
slivers: [
const AddToAlbumHeader(),
@@ -4,6 +4,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
@@ -15,7 +16,6 @@ import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -94,7 +94,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
],
const DownloadActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DownloadAction(source: .timeline)),
if (ownsAlbum) ...[
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const ActionColumnButton(action: EditDateTimeAction(source: .timeline)),
@@ -10,9 +10,7 @@ import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
@@ -57,11 +55,6 @@ class ActionNotifier extends Notifier<void> {
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
}
List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
final ownerId = ref.read(currentUserProvider)?.id;
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().toList(growable: false);
}
Set<BaseAsset> _getAssets(ActionSource source) {
return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
@@ -102,23 +95,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
final count = await _service.tagAssets(ids, context);
if (count == null) {
return null;
}
ref.invalidate(tagProvider);
return ActionResult(count: count, success: true);
} catch (error, stack) {
_logger.severe('Failed to tag assets', error, stack);
ref.invalidate(tagProvider);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> addToAlbum(ActionSource source, RemoteAlbum album) async {
final selected = _getAssets(source).toList(growable: false);
if (selected.isEmpty) {
@@ -201,18 +177,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> downloadAll(ActionSource source) async {
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
try {
final didEnqueue = await _service.downloadAll(assets);
final enqueueCount = didEnqueue.where((e) => e).length;
return ActionResult(count: enqueueCount, success: true);
} catch (error, stack) {
_logger.severe('Failed to download assets', error, stack);
return ActionResult(count: assets.length, success: false, error: error.toString());
}
}
Future<ActionResult> upload(
ActionSource source, {
List<LocalAsset>? assets,
@@ -293,16 +257,8 @@ class ActionNotifier extends Notifier<void> {
});
}
}
}
extension on Iterable<RemoteAsset> {
Iterable<String> toIds() => map((e) => e.id);
Iterable<RemoteAsset> ownedAssets(String? ownerId) {
if (ownerId == null) {
return const [];
}
return whereType<RemoteAsset>().where((a) => a.ownerId == ownerId);
}
}
+2 -43
View File
@@ -1,36 +1,19 @@
import 'dart:async';
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/services/tag.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
final actionServiceProvider = Provider<ActionService>(
(ref) => ActionService(
ref.watch(assetApiRepositoryProvider),
ref.watch(remoteAssetRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
),
(ref) => ActionService(ref.watch(assetApiRepositoryProvider), ref.watch(remoteAssetRepositoryProvider)),
);
class ActionService {
final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
const ActionService(
this._assetApiRepository,
this._remoteAssetRepository,
this._downloadRepository,
this._tagService,
);
const ActionService(this._assetApiRepository, this._remoteAssetRepository);
Future<int> emptyTrash(String userId) async {
final count = await _assetApiRepository.emptyTrash();
@@ -59,28 +42,4 @@ class ActionService {
return true;
}
Future<int?> tagAssets(List<String> remoteIds, BuildContext context) async {
final tagResults = await showTagPickerModal(context: context);
if (tagResults == null) {
// user cancelled
return null;
}
final selectedTagIds = Set<String>.from(tagResults.$1);
final selectedNewTagValues = tagResults.$2;
if (selectedNewTagValues.isNotEmpty) {
final upsertedTags = await _tagService.upsertTags(selectedNewTagValues.toList());
selectedTagIds.addAll(upsertedTags.map((t) => t.id));
}
if (selectedTagIds.isEmpty) {
return 0;
}
return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList());
}
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
return _downloadRepository.downloadAllAssets(assets);
}
}
+2 -2
View File
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/cast.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart';
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
@@ -24,7 +25,6 @@ import 'package:immich_mobile/presentation/actions/similar_photos.action.dart';
import 'package:immich_mobile/presentation/actions/slideshow.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -178,7 +178,7 @@ enum ActionButtonType {
ActionButtonType.slideshow => const ActionMenuItem(action: SlideshowAction()),
ActionButtonType.archive ||
ActionButtonType.unarchive => ActionMenuItem(action: ArchiveAction(source: context.source)),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.download => ActionMenuItem(action: DownloadAction(source: context.source)),
ActionButtonType.restoreTrash => ActionMenuItem(action: RestoreAction(source: context.source)),
ActionButtonType.delete => ActionMenuItem(action: DeleteAction(source: context.source)),
ActionButtonType.moveToLockFolder ||
@@ -17,8 +17,6 @@ void main() {
late MockAssetApiRepository assetApiRepository;
late MockRemoteAssetRepository remoteAssetRepository;
late MockDownloadRepository downloadRepository;
late MockTagService tagService;
late Drift db;
@@ -39,10 +37,8 @@ void main() {
setUp(() {
assetApiRepository = MockAssetApiRepository();
remoteAssetRepository = MockRemoteAssetRepository();
downloadRepository = MockDownloadRepository();
tagService = MockTagService();
sut = ActionService(assetApiRepository, remoteAssetRepository, downloadRepository, tagService);
sut = ActionService(assetApiRepository, remoteAssetRepository);
});
tearDown(() async {
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/tag.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/tag.action.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../../repository.mocks.dart';
import '../../factories/local_asset_factory.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockTagService tagService;
setUp(() async {
context = await PresentationContext.create();
tagService = context.service.tag.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
group('DownloadAction', () {
Future<void> pumpDownload(WidgetTester tester, Set<BaseAsset> selection) => tester.pumpTestAction(
context,
const DownloadAction(source: .timeline),
overrides: [
...context.selected(selection),
downloadRepositoryProvider.overrideWithValue(context.repository.download.repo),
backgroundSyncProvider.overrideWithValue(context.service.backgroundSync),
],
);
Future<void> settleDownload(WidgetTester tester) async {
await tester.pump();
await tester.pump(const .new(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets('downloads every selected remote asset', (tester) async {
final asset = owned();
await pumpDownload(tester, {asset});
await settleDownload(tester);
verify(() => context.repository.download.repo.downloadAllAssets([asset])).called(1);
});
testWidgets('ignores local-only assets, which are already on the device', (tester) async {
final remote = owned();
await pumpDownload(tester, {remote, LocalAssetFactory.create()});
await settleDownload(tester);
verify(() => context.repository.download.repo.downloadAllAssets([remote])).called(1);
});
testWidgets('is hidden when nothing remote is selected', (tester) async {
await tester.pumpTestWidget(
context,
const ActionIconButton(action: DownloadAction(source: .timeline)),
overrides: context.selected({LocalAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
group('TagAction', () {
late BuildContext actionContext;
late WidgetRef actionRef;
Future<void> pumpTag(WidgetTester tester, Set<BaseAsset> selection) => tester.pumpTestWidget(
context,
Consumer(
builder: (widgetContext, ref, _) {
actionContext = widgetContext;
actionRef = ref;
return const ActionIconButton(action: TagAction(source: .timeline));
},
),
overrides: [
...context.selected(selection),
toastServiceProvider.overrideWithValue(context.service.toast),
tagServiceProvider.overrideWithValue(tagService),
userMetadataPreferencesProvider.overrideWith((ref) async => const .new(tagsEnabled: true)),
],
);
Future<void> applyTags(List<String> assetIds, {Set<String> selected = const {}, Set<String> created = const {}}) =>
tagAssets(actionContext, actionRef, assetIds, selected: selected, created: created);
testWidgets('offers tagging for an owned asset', (tester) async {
await pumpTag(tester, {owned()});
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('is hidden without any owned asset', (tester) async {
await pumpTag(tester, {RemoteAssetFactory.create()});
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('applies the picked tags and reports the count', (tester) async {
final asset = owned();
when(() => tagService.bulkTagAssets(any(), any())).thenAnswer((_) async => 1);
await pumpTag(tester, {asset});
await applyTags([asset.id], selected: {'tag-1'});
await tester.pumpAndSettle();
verify(() => tagService.bulkTagAssets([asset.id], ['tag-1'])).called(1);
final message = verify(() => context.service.toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.tagged_assets(count: 1));
});
testWidgets('creates new tags first and applies them alongside the picked ones', (tester) async {
final asset = owned();
when(() => tagService.upsertTags(any())).thenAnswer((_) async => [const Tag(id: 'made-1', value: 'brand new')]);
when(() => tagService.bulkTagAssets(any(), any())).thenAnswer((_) async => 1);
await pumpTag(tester, {asset});
await applyTags([asset.id], selected: {'tag-1'}, created: {'brand new'});
await tester.pumpAndSettle();
verify(() => tagService.upsertTags(['brand new'])).called(1);
final tagIds = verify(() => tagService.bulkTagAssets([asset.id], captureAny())).captured.single as List<String>;
expect(tagIds, containsAll(['tag-1', 'made-1']));
});
testWidgets('does nothing when no tag was chosen or created', (tester) async {
final asset = owned();
await pumpTag(tester, {asset});
await applyTags([asset.id]);
await tester.pumpAndSettle();
verifyNever(() => tagService.bulkTagAssets(any(), any()));
verifyNever(() => context.service.toast.success(any()));
});
});
}