diff --git a/i18n/en.json b/i18n/en.json index 95e958403283a..aa8b768311746 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -819,6 +819,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", @@ -1070,6 +1071,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 0000000000000..2bcdf7f2fdf22 --- /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 { + await _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 e81b79c459c51..6b047cadec400 100644 --- a/mobile/lib/infrastructure/repositories/tags_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/tags_api.repository.dart @@ -14,4 +14,12 @@ class TagsApiRepository extends ApiRepository { Future?> getAllTags() async { return await _api.getAllTags(); } + + Future bulkTagAssets(List assetIds, List tagIds) async { + await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds)); + } + + 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 45a14643c991d..c045dbd325432 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -209,7 +209,7 @@ class DriftSearchPage extends HookConsumerWidget { expanded: true, onSearch: search, onClear: handleClear, - child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), + child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), ), ), ); @@ -696,7 +696,7 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_location'.t(context: context), currentFilter: locationCurrentFilterWidget.value, ), - if (userPreferences.value?.tagsEnabled ?? false) + if (userPreferences.valueOrNull?.tagsEnabled ?? false) SearchFilterChip( icon: Icons.sell_outlined, onTap: showTagPicker, @@ -722,14 +722,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), - if (userPreferences.value?.ratingsEnabled ?? false) ...[ + if (userPreferences.valueOrNull?.ratingsEnabled ?? false) SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, label: 'search_filter_star_rating'.t(context: context), currentFilter: ratingCurrentFilterWidget.value, ), - ], SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, 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 0000000000000..e55339b22d771 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart @@ -0,0 +1,49 @@ +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}); + + _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).tagAssets(source, context); + if (result == null) { + return; + } + + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'tagged_assets'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : '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( + maxWidth: 95.0, + iconData: Icons.sell, + 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 fea3da88e5392..4493244dcc9b9 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'; @@ -114,6 +115,7 @@ class _GeneralBottomSheetState extends ConsumerState { : const DeletePermanentActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), + 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 f6d05277aba6c..8187c7bac3111 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -332,6 +332,21 @@ class ActionNotifier extends Notifier { } } + Future tagAssets(ActionSource source, BuildContext context) async { + final ids = _getOwnedRemoteIdsForSource(source); + try { + final isTagged = await _service.tagAssets(ids, context); + if (!isTagged) { + return null; + } + + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to tag assets', error, stack); + 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 23d4d86861511..dbee7673456c7 100644 --- a/mobile/lib/providers/infrastructure/tag.provider.dart +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -1,16 +1,25 @@ 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> { + late final TagService service; + @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(); + final service = ref.watch(tagServiceProvider); + return await service.getAllTags(); + } + + Future bulkTagAssets(List assetIds, List tagIds) async { + await service.bulkTagAssets(assetIds, tagIds); + } + + Future> upsertTags(List tags) async { + final upsertedTags = await service.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 3d3ef1494cba3..af070f33df870 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart'; @@ -22,6 +23,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; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -35,6 +37,7 @@ final actionServiceProvider = Provider( ref.watch(trashedLocalAssetRepository), ref.watch(assetMediaRepositoryProvider), ref.watch(downloadRepositoryProvider), + ref.watch(tagProvider.notifier), ), ); @@ -47,6 +50,7 @@ class ActionService { final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; final DownloadRepository _downloadRepository; + final TagNotifier _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 { @@ -222,6 +227,26 @@ class ActionService { return true; } + Future tagAssets(List remoteIds, BuildContext context) async { + final tagResults = await showTagPickerModal(context: context); + if (tagResults == null) { + // user cancelled + return false; + } + + final selectedTagIds = 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.isNotEmpty) { + await _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList()); + } + return true; + } + 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 0ab25d14cb923..f1bec85ea6e8b 100644 --- a/mobile/lib/widgets/common/tag_picker.dart +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -8,12 +8,76 @@ 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'; +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.onSelect, required this.filter}); + 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 +85,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 +106,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 +178,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))); }, ), ), @@ -86,4 +191,8 @@ class TagPicker extends HookConsumerWidget { ], ); } + + String _trimSlashes(String s) { + return s.replaceAll(RegExp(r'^/+|/+$'), ''); + } } diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 4b54ec4055fea..8780a3388f69f 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:mocktail/mocktail.dart'; class MockAlbumRepository extends Mock implements AlbumRepository {} @@ -46,3 +47,5 @@ class MockPartnerRepository extends Mock implements PartnerRepository {} class MockPartnerApiRepository extends Mock implements PartnerApiRepository {} class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {} + +class MockTagNotifier extends Mock implements TagNotifier {} diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index 87263c9ae7dd7..d50e08afb133c 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 MockTagNotifier tagNotifier; late Drift db; @@ -53,6 +54,7 @@ void main() { trashedLocalAssetRepository = MockTrashedLocalAssetRepository(); assetMediaRepository = MockAssetMediaRepository(); downloadRepository = MockDownloadRepository(); + tagNotifier = MockTagNotifier(); sut = ActionService( assetApiRepository, @@ -63,6 +65,7 @@ void main() { trashedLocalAssetRepository, assetMediaRepository, downloadRepository, + tagNotifier, ); });