mirror of
https://github.com/immich-app/immich.git
synced 2026-08-01 08:30:54 -07:00
refactor: mobile tag and download action
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
-46
@@ -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)),
|
||||
],
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user