feat(mobile): "Add Tags" asset multiselect option (#26269)

* add bulk_tag_assets_action_button to general_bottom_sheet.widget

include create tag tile in 'Add Tags' action modal

* follow provider -> svc -> repo pattern for tags

* rebase and cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Benjamin Nguyen
2026-05-18 07:59:09 -07:00
committed by GitHub
parent 3a3469a5f9
commit 24b1dae9f2
12 changed files with 270 additions and 14 deletions
+2
View File
@@ -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",
@@ -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<TagService>((ref) => TagService(ref.watch(tagsApiRepositoryProvider)));
class TagService {
final TagsApiRepository _repository;
const TagService(this._repository);
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
return _repository.bulkTagAssets(assetIds, tagIds);
}
Future<Set<Tag>> getAllTags() async {
final dtos = await _repository.getAllTags();
if (dtos == null) {
return {};
}
return dtos.map((dto) => Tag.fromDto(dto)).toSet();
}
Future<List<Tag>> upsertTags(List<String> tags) async {
final dtos = await _repository.upsertTags(tags);
if (dtos == null) {
return [];
}
return dtos.map((dto) => Tag.fromDto(dto)).toList();
}
}
@@ -14,4 +14,13 @@ class TagsApiRepository extends ApiRepository {
Future<List<TagResponseDto>?> getAllTags() async {
return await _api.getAllTags();
}
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds));
return response?.count ?? 0;
}
Future<List<TagResponseDto>?> upsertTags(List<String> tags) async {
return _api.upsertTags(TagUpsertDto(tags: tags));
}
}
@@ -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()),
),
),
);
@@ -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<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),
);
}
}
@@ -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<GeneralBottomSheet> {
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<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
@@ -114,6 +119,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
: 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),
@@ -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<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> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source);
try {
@@ -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<Set<Tag>> {
@override
Future<Set<Tag>> 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<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
return ref.read(tagServiceProvider).bulkTagAssets(assetIds, tagIds);
}
Future<List<Tag>> upsertTags(List<String> tags) async {
final upsertedTags = await ref.read(tagServiceProvider).upsertTags(tags);
state = AsyncValue.data({...?state.valueOrNull, ...upsertedTags});
return upsertedTags;
}
}
+25
View File
@@ -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<ActionService>(
@@ -35,6 +37,7 @@ final actionServiceProvider = Provider<ActionService>(
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<void> shareLink(List<String> remoteIds, BuildContext context) async {
@@ -234,6 +239,26 @@ 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<void> stack(String userId, List<String> remoteIds) async {
final stack = await _assetApiRepository.stack(remoteIds);
await _remoteAssetRepository.stack(userId, stack);
+113 -6
View File
@@ -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<String>, Set<String>)?> showTagPickerModal({required BuildContext context, Set<String>? initialSelection}) {
return showDialog<(Set<String>, Set<String>)?>(
context: context,
builder: (context) => _TagPickerModal(initialSelection: initialSelection),
);
}
class _TagPickerModal extends HookConsumerWidget {
final Set<String>? initialSelection;
const _TagPickerModal({this.initialSelection});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedTagIds = useState<Set<String>>(initialSelection ?? {});
final newTagValues = useState<Set<String>>({});
void onSelectExistingTag(Iterable<Tag> tags) {
selectedTagIds.value = tags.map((tag) => tag.id).toSet();
}
void onSelectNewTag(Set<String> 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<Tag>) onSelect;
final Set<String> filter;
/// Callback when existing tags are selected/deselected.
final Function(Iterable<Tag>) onSelectExistingTag;
/// If not null, shows a tile to create a new tag with user's filter input.
final Function(Set<String>)? 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<Set<String>>(filter);
final borderRadius = const BorderRadius.all(Radius.circular(10));
final selectedNewTagValues = useState<Set<String>>({});
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)));
},
),
),
+3
View File
@@ -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 {}
@@ -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,
);
});