diff --git a/i18n/en.json b/i18n/en.json index 697aa7f2fa..3674d47e17 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -823,6 +823,7 @@ "contain": "Contain", "context": "Context", "continue": "Continue", + "control_bottom_app_bar_add_tags": "Add Tags", "control_bottom_app_bar_create_new_album": "Create new album", "control_bottom_app_bar_delete_from_immich": "Delete from Immich", "control_bottom_app_bar_delete_from_local": "Delete from device", @@ -1074,6 +1075,7 @@ "failed_to_remove_product_key": "Failed to remove product key", "failed_to_reset_pin_code": "Failed to reset PIN code", "failed_to_stack_assets": "Failed to stack assets", + "failed_to_tag_assets": "Failed to tag assets", "failed_to_unstack_assets": "Failed to un-stack assets", "failed_to_update_notification_status": "Failed to update notification status", "incorrect_email_or_password": "Incorrect email or password", diff --git a/mobile/lib/domain/services/tag.service.dart b/mobile/lib/domain/services/tag.service.dart new file mode 100644 index 0000000000..6eeb83d4cb --- /dev/null +++ b/mobile/lib/domain/services/tag.service.dart @@ -0,0 +1,31 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart'; + +final tagServiceProvider = Provider((ref) => TagService(ref.watch(tagsApiRepositoryProvider))); + +class TagService { + final TagsApiRepository _repository; + + const TagService(this._repository); + + Future bulkTagAssets(List assetIds, List tagIds) async { + return _repository.bulkTagAssets(assetIds, tagIds); + } + + Future> getAllTags() async { + final dtos = await _repository.getAllTags(); + if (dtos == null) { + return {}; + } + return dtos.map((dto) => Tag.fromDto(dto)).toSet(); + } + + Future> upsertTags(List tags) async { + final dtos = await _repository.upsertTags(tags); + if (dtos == null) { + return []; + } + return dtos.map((dto) => Tag.fromDto(dto)).toList(); + } +} diff --git a/mobile/lib/infrastructure/repositories/tags_api.repository.dart b/mobile/lib/infrastructure/repositories/tags_api.repository.dart index e81b79c459..5963fc2f23 100644 --- a/mobile/lib/infrastructure/repositories/tags_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/tags_api.repository.dart @@ -14,4 +14,13 @@ class TagsApiRepository extends ApiRepository { Future?> getAllTags() async { return await _api.getAllTags(); } + + Future bulkTagAssets(List assetIds, List tagIds) async { + final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds)); + return response?.count ?? 0; + } + + Future?> upsertTags(List tags) async { + return _api.upsertTags(TagUpsertDto(tags: tags)); + } } diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 7b747738dd..2a40556641 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -186,7 +186,7 @@ class DriftSearchPage extends HookConsumerWidget { expanded: true, onSearch: handleApply, onClear: handleClear, - child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), + child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), ), ), ); diff --git a/mobile/lib/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart new file mode 100644 index 0000000000..b9ac47cd57 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart @@ -0,0 +1,46 @@ +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 _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), + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 8753a9c14f..0bafacfe54 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; @@ -26,6 +27,7 @@ import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.d import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -57,6 +59,9 @@ class _GeneralBottomSheetState extends ConsumerState { final multiselect = ref.watch(multiSelectProvider); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); + final tagsEnabled = ref.watch( + userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false), + ); Future addAssetsToAlbum(RemoteAlbum album) async { final selectedAssets = multiselect.selectedAssets; @@ -114,6 +119,7 @@ class _GeneralBottomSheetState extends ConsumerState { : const DeletePermanentActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), + if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 8b3dd7d73e..d73f3f1963 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -13,6 +13,7 @@ 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/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider; +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/providers/websocket.provider.dart'; @@ -353,6 +354,23 @@ class ActionNotifier extends Notifier { } } + Future 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 removeFromAlbum(ActionSource source, String albumId) async { final ids = _getRemoteIdsForSource(source); try { diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart index 23d4d86861..2d527768c5 100644 --- a/mobile/lib/providers/infrastructure/tag.provider.dart +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -1,16 +1,22 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/tag.model.dart'; -import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart'; +import 'package:immich_mobile/domain/services/tag.service.dart'; class TagNotifier extends AsyncNotifier> { @override Future> build() async { - final repo = ref.read(tagsApiRepositoryProvider); - final allTags = await repo.getAllTags(); - if (allTags == null) { - return {}; - } - return allTags.map((t) => Tag.fromDto(t)).toSet(); + return ref.watch(tagServiceProvider).getAllTags(); + } + + Future bulkTagAssets(List assetIds, List tagIds) async { + return ref.read(tagServiceProvider).bulkTagAssets(assetIds, tagIds); + } + + Future> upsertTags(List tags) async { + final upsertedTags = await ref.read(tagServiceProvider).upsertTags(tags); + + state = AsyncValue.data({...?state.valueOrNull, ...upsertedTags}); + return upsertedTags; } } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 4e51c32f97..b9e601ca25 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -7,6 +7,7 @@ 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_edit.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/tag.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; @@ -23,6 +24,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; +import 'package:immich_mobile/widgets/common/tag_picker.dart'; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; final actionServiceProvider = Provider( @@ -35,6 +37,7 @@ final actionServiceProvider = Provider( ref.watch(trashedLocalAssetRepository), ref.watch(assetMediaRepositoryProvider), ref.watch(downloadRepositoryProvider), + ref.watch(tagServiceProvider), ), ); @@ -47,6 +50,7 @@ class ActionService { final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; final DownloadRepository _downloadRepository; + final TagService _tagService; const ActionService( this._assetApiRepository, @@ -57,6 +61,7 @@ class ActionService { this._trashedLocalAssetRepository, this._assetMediaRepository, this._downloadRepository, + this._tagService, ); Future shareLink(List remoteIds, BuildContext context) async { @@ -234,6 +239,26 @@ class ActionService { return true; } + Future tagAssets(List remoteIds, BuildContext context) async { + final tagResults = await showTagPickerModal(context: context); + if (tagResults == null) { + // user cancelled + return null; + } + + final selectedTagIds = Set.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 stack(String userId, List remoteIds) async { final stack = await _assetApiRepository.stack(remoteIds); await _remoteAssetRepository.stack(userId, stack); diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart index 0ab25d14cb..0265cf7e6c 100644 --- a/mobile/lib/widgets/common/tag_picker.dart +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -8,12 +8,78 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; -class TagPicker extends HookConsumerWidget { - const TagPicker({super.key, required this.onSelect, required this.filter}); +String _trimSlashes(String s) => s.replaceAll(RegExp(r'^/+|/+$'), ''); + +Future<(Set, Set)?> showTagPickerModal({required BuildContext context, Set? initialSelection}) { + return showDialog<(Set, Set)?>( + context: context, + builder: (context) => _TagPickerModal(initialSelection: initialSelection), + ); +} + +class _TagPickerModal extends HookConsumerWidget { + final Set? initialSelection; + + const _TagPickerModal({this.initialSelection}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedTagIds = useState>(initialSelection ?? {}); + final newTagValues = useState>({}); + + void onSelectExistingTag(Iterable tags) { + selectedTagIds.value = tags.map((tag) => tag.id).toSet(); + } + + void onSelectNewTag(Set tags) { + newTagValues.value = tags; + } + + return AlertDialog( + contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 0), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + "cancel", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colorScheme.error, + ), + ).tr(), + ), + TextButton( + onPressed: () => context.pop((selectedTagIds.value, newTagValues.value)), + child: Text( + "action_common_update", + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor), + ).tr(), + ), + ], + content: SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + height: MediaQuery.of(context).size.height * 0.6, + child: TagPicker( + onSelectExistingTag: onSelectExistingTag, + filter: selectedTagIds.value, + onSelectNewTag: onSelectNewTag, + ), + ), + ); + } +} + +class TagPicker extends HookConsumerWidget { + const TagPicker({super.key, required this.onSelectExistingTag, required this.filter, this.onSelectNewTag}); - final Function(Iterable) onSelect; final Set filter; + /// Callback when existing tags are selected/deselected. + final Function(Iterable) onSelectExistingTag; + + /// If not null, shows a tile to create a new tag with user's filter input. + final Function(Set)? onSelectNewTag; + @override Widget build(BuildContext context, WidgetRef ref) { final formFocus = useFocusNode(); @@ -21,6 +87,7 @@ class TagPicker extends HookConsumerWidget { final tags = ref.watch(tagProvider); final selectedTagIds = useState>(filter); final borderRadius = const BorderRadius.all(Radius.circular(10)); + final selectedNewTagValues = useState>({}); return Column( children: [ @@ -41,13 +108,53 @@ class TagPicker extends HookConsumerWidget { Expanded( child: tags.widgetWhen( onData: (tags) { + final trimmedQuery = _trimSlashes(searchQuery.value); final queryResult = tags - .where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase())) + .where((t) => t.value.toLowerCase().contains(trimmedQuery.toLowerCase())) .toList(); + final showCreateTile = + (onSelectNewTag != null) && + trimmedQuery.isNotEmpty && + !tags.any((t) => t.value.toLowerCase() == trimmedQuery.toLowerCase()); + final isCreateSelected = selectedNewTagValues.value.contains(trimmedQuery); return ListView.builder( - itemCount: queryResult.length, + itemCount: queryResult.length + (showCreateTile ? 1 : 0), padding: const EdgeInsets.all(8), itemBuilder: (context, index) { + if (showCreateTile && index == queryResult.length) { + // Create new tag tile + return Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Container( + decoration: BoxDecoration( + color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25), + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + child: ListTile( + title: Text( + trimmedQuery, + style: context.textTheme.bodyLarge?.copyWith( + color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + ), + trailing: Icon( + Icons.add, + color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + onTap: () { + final newSelectedNewTagValues = {...selectedNewTagValues.value}; + if (isCreateSelected) { + newSelectedNewTagValues.remove(trimmedQuery); + } else { + newSelectedNewTagValues.add(trimmedQuery); + } + selectedNewTagValues.value = newSelectedNewTagValues; + onSelectNewTag!.call(newSelectedNewTagValues); + }, + ), + ), + ); + } final tag = queryResult[index]; final isSelected = selectedTagIds.value.any((id) => id == tag.id); @@ -73,7 +180,7 @@ class TagPicker extends HookConsumerWidget { newSelected.add(tag.id); } selectedTagIds.value = newSelected; - onSelect(tags.where((t) => newSelected.contains(t.id))); + onSelectExistingTag(tags.where((t) => newSelected.contains(t.id))); }, ), ), diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index d049626f1d..4b27541246 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -2,6 +2,7 @@ import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart'; +import 'package:immich_mobile/domain/services/tag.service.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,3 +15,5 @@ class MockAuthApiRepository extends Mock implements AuthApiRepository {} class MockAuthRepository extends Mock implements AuthRepository {} class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {} + +class MockTagService extends Mock implements TagService {} diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index 87263c9ae7..f08a247a3e 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -27,6 +27,7 @@ void main() { late MockTrashedLocalAssetRepository trashedLocalAssetRepository; late MockAssetMediaRepository assetMediaRepository; late MockDownloadRepository downloadRepository; + late MockTagService tagService; late Drift db; @@ -53,6 +54,7 @@ void main() { trashedLocalAssetRepository = MockTrashedLocalAssetRepository(); assetMediaRepository = MockAssetMediaRepository(); downloadRepository = MockDownloadRepository(); + tagService = MockTagService(); sut = ActionService( assetApiRepository, @@ -63,6 +65,7 @@ void main() { trashedLocalAssetRepository, assetMediaRepository, downloadRepository, + tagService, ); });