From 1daf9225555b6a93266fdfeeaaa094e7ce0f4d23 Mon Sep 17 00:00:00 2001 From: Will <90420132+staticdog@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:40:26 -0400 Subject: [PATCH] feat(mobile): mobile tags (#30753) * feat(mobile): mobile tags * fix(mobile); refactoring, replaced hook * tag provider through asset service * make search use a tag set * use tag action directly * use notification listener * untag action * handle untag response * fix tests and add untag action test * fix material error from tag picker dialog * fix(mobile): clip tag picker tiles and update tag overflow --------- Co-authored-by: Alex Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Santo Shakil (cherry picked from commit 83c7ec50056bbefa10869c78457f8882c72275e2) --- i18n/en.json | 1 + mobile/lib/data/server/tag.dart | 6 + mobile/lib/data/store/tag.dart | 21 +++ mobile/lib/domain/services/asset.service.dart | 5 + .../repositories/search_api.repository.dart | 8 +- .../models/search/search_filter.model.dart | 5 +- .../lib/presentation/actions/tag.action.dart | 30 ++- .../pages/search/search.page.dart | 60 +++--- .../asset_viewer/asset_details.widget.dart | 2 + .../asset_details/tag_details.widget.dart | 178 ++++++++++++++++++ .../repositories/asset_api.repository.dart | 6 + mobile/lib/widgets/common/tag_picker.dart | 63 ++++--- mobile/test/unit/mocks.dart | 4 + .../actions/download_tag_action_test.dart | 40 ++++ 14 files changed, 363 insertions(+), 66 deletions(-) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/tag_details.widget.dart diff --git a/i18n/en.json b/i18n/en.json index 25a4f1913..e22e45b6c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2339,6 +2339,7 @@ "view_group": "View group", "view_in_timeline": "View in timeline", "view_link": "View link", + "view_more": "View more", "view_name": "View", "view_next_asset": "View next asset", "view_previous_asset": "View previous asset", diff --git a/mobile/lib/data/server/tag.dart b/mobile/lib/data/server/tag.dart index 106051ee5..4cd96c109 100644 --- a/mobile/lib/data/server/tag.dart +++ b/mobile/lib/data/server/tag.dart @@ -17,6 +17,12 @@ class TagApiRepository extends ApiRepository { return response?.count ?? 0; } + /// Remove the tag [tagId] from every asset in [assetIds], returning the number of assets successfully untagged + Future untagAssets(String tagId, List assetIds) async { + final response = await _api.untagAssets(tagId, BulkIdsDto(ids: assetIds)); + return response?.where((result) => result.success).length ?? 0; + } + /// Retrieves all known tags Future> getAll() async { final response = await checkNull(_api.getAllTags()); diff --git a/mobile/lib/data/store/tag.dart b/mobile/lib/data/store/tag.dart index ff0739aa5..0706ea889 100644 --- a/mobile/lib/data/store/tag.dart +++ b/mobile/lib/data/store/tag.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/data/server/tag.dart'; import 'package:immich_mobile/data/store/util/cache.dart'; import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:logging/logging.dart'; final _log = Logger("TagStore"); @@ -16,6 +17,12 @@ extension type const TagStore._(Provider _provider) implements Pro /// /// **NOTE:** This is not reactive to changes, and only hits the HTTP API AutoDisposeFutureProvider> all() => _allProvider; + + /// The tags applied to the asset [assetId] + /// + /// **NOTE:** This is not reactive to changes, and only hits the HTTP API. Invalidate the whole family after a + /// mutation, since a single change can affect many assets + AutoDisposeFutureProviderFamily, String> get forAsset => _forAssetProvider; } final _allProvider = FutureProvider.autoDispose>((ref) async { @@ -27,6 +34,10 @@ final _allProvider = FutureProvider.autoDispose>((ref) async { } }); +final _forAssetProvider = FutureProvider.autoDispose.family, String>( + (ref, assetId) => ref.watch(assetServiceProvider).getTags(assetId), +); + class TagMutations extends StoreMutations { const TagMutations._(super.ref); @@ -49,4 +60,14 @@ class TagMutations extends StoreMutations { rethrow; } } + + /// Remove the tag [tagId] from every asset in [assetIds], returning the number of assets successfully untagged + Future removeFromAssets(String tagId, List assetIds) async { + try { + return await read(tagApiRepositoryProvider).untagAssets(tagId, assetIds); + } catch (error, stack) { + _log.severe("Failed to untag assets", error, stack); + rethrow; + } + } } diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index cfb34097b..58de42ba7 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -3,6 +3,7 @@ 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/exif.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.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'; @@ -198,6 +199,10 @@ class AssetService { return _localRepository.get(id); } + Future> getTags(String assetId) { + return _apiRepository.getAssetTags(assetId); + } + Future stackEditedUpload(String localId, String remoteId, String? checksum) async { // a manual upload of an asset that was never hashed has no checksum yet, so we need to fetch it from the server final uploadedChecksum = checksum ?? await _apiRepository.getChecksum(remoteId); diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 1f513f5c4..e6840a875 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -17,6 +17,10 @@ class SearchApiRepository extends ApiRepository { type = AssetTypeEnum.VIDEO; } + final tagIds = filter.tags == null + ? const Optional>.absent() + : Optional.present(filter.tags!.map((tag) => tag.id).toList()); + if ((filter.context != null && filter.context!.isNotEmpty) || (filter.assetId != null && filter.assetId!.isNotEmpty)) { return _api.searchSmart( @@ -42,7 +46,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(), isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(), personIds: Optional.present(filter.people.map((e) => e.id).toList()), - tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds), + tagIds: tagIds, type: type == null ? const Optional.absent() : Optional.present(type), page: Optional.present(page), size: const Optional.present(100), @@ -73,7 +77,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(), isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(), personIds: Optional.present(filter.people.map((e) => e.id).toList()), - tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds), + tagIds: tagIds, type: type == null ? const Optional.absent() : Optional.present(type), page: Optional.present(page), size: const Optional.present(1000), diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index dfefce029..09857d645 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -1,6 +1,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/utils/option.dart'; part 'search_filter.model.freezed.dart'; @@ -52,7 +53,7 @@ abstract class SearchFilter with _$SearchFilter { String? ocr, String? language, String? assetId, - List? tagIds, + Set? tags, required Set people, required SearchLocationFilter location, required SearchCameraFilter camera, @@ -68,7 +69,7 @@ abstract class SearchFilter with _$SearchFilter { (description == null || (description!.isEmpty)) && (assetId == null || (assetId!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) && - (tagIds ?? []).isEmpty && + (tags?.isEmpty ?? true) && people.isEmpty && location.country == null && location.state == null && diff --git a/mobile/lib/presentation/actions/tag.action.dart b/mobile/lib/presentation/actions/tag.action.dart index 11293f438..07e39cbfe 100644 --- a/mobile/lib/presentation/actions/tag.action.dart +++ b/mobile/lib/presentation/actions/tag.action.dart @@ -40,7 +40,6 @@ class TagAction extends AssetActionBuilder { Future _tag(BuildContext context, WidgetRef ref, List assetIds) async { final clearSelection = ref.read(clearSelectionProvider(source)); - try { final results = await showTagPickerModal(context: context); if (results == null || !context.mounted) { @@ -77,7 +76,36 @@ Future tagAssets( } final count = await ref.read(Store.tags).applyToAssets(assetIds, tagIds.toList()); + ref.invalidate(Store.tags.forAsset); if (context.mounted) { toastService.success(context.t.tagged_assets(count: count)); } } + +class UntagAction extends AssetActionBuilder { + final String tagId; + + const UntagAction({required super.source, required this.tagId}); + + @override + ActionItem? create(BuildContext context, WidgetRef ref) { + final assetIds = ref.watch(_stateProvider(source)); + if (assetIds == null) { + return null; + } + + return .new(icon: Icons.close, label: context.t.remove_tag, onAction: () => _untag(context, ref, assetIds, tagId)); + } + + Future _untag(BuildContext context, WidgetRef ref, List assetIds, String tagId) async { + try { + final count = await ref.read(Store.tags).removeFromAssets(tagId, assetIds); + ref.invalidate(Store.tags.forAsset); + if (context.mounted) { + ref.read(toastServiceProvider).success(context.t.removed_tagged_assets(count: count)); + } + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to remove the tag"); + } + } +} diff --git a/mobile/lib/presentation/pages/search/search.page.dart b/mobile/lib/presentation/pages/search/search.page.dart index cb75d2d5b..a647d2a05 100644 --- a/mobile/lib/presentation/pages/search/search.page.dart +++ b/mobile/lib/presentation/pages/search/search.page.dart @@ -59,7 +59,7 @@ class SearchPage extends HookConsumerWidget { rating: const SearchRatingFilter(), mediaType: AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", - tagIds: [], + tags: {}, ), ); @@ -94,6 +94,26 @@ class SearchPage extends HookConsumerWidget { unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value)); } + void applyPreFilter(SearchFilter preFilter) { + if (!context.mounted) { + return; + } + + textSearchController.clear(); + peopleCurrentFilterWidget.value = null; + dateRangeCurrentFilterWidget.value = null; + cameraCurrentFilterWidget.value = null; + mediaTypeCurrentFilterWidget.value = null; + ratingCurrentFilterWidget.value = null; + displayOptionCurrentFilterWidget.value = null; + locationCurrentFilterWidget.value = preFilter.location.city != null + ? Text(preFilter.location.city!, style: context.textTheme.labelLarge) + : null; + final tagLabel = (preFilter.tags ?? const {}).map((tag) => tag.value).join(', '); + tagCurrentFilterWidget.value = tagLabel.isNotEmpty ? Text(tagLabel, style: context.textTheme.labelLarge) : null; + search(preFilter); + } + // TODO: Use ref.listen with `fireImmediately` in the new riverpod version. final preFilter = ref.watch(searchPreFilterProvider); useEffect(() { @@ -101,26 +121,7 @@ class SearchPage extends HookConsumerWidget { return null; } - unawaited( - Future.microtask(() { - if (!context.mounted) { - return; - } - - textSearchController.clear(); - peopleCurrentFilterWidget.value = null; - dateRangeCurrentFilterWidget.value = null; - cameraCurrentFilterWidget.value = null; - tagCurrentFilterWidget.value = null; - mediaTypeCurrentFilterWidget.value = null; - ratingCurrentFilterWidget.value = null; - displayOptionCurrentFilterWidget.value = null; - locationCurrentFilterWidget.value = preFilter.location.city != null - ? Text(preFilter.location.city!, style: context.textTheme.labelLarge) - : null; - search(preFilter); - }), - ); + unawaited(Future.microtask(() => applyPreFilter(preFilter))); return null; }, [preFilter]); @@ -162,22 +163,21 @@ class SearchPage extends HookConsumerWidget { } void showTagPicker() { - var tagIds = filter.value.tagIds ?? []; - String tagLabel = ''; + var tags = filter.value.tags ?? const {}; - void handleOnSelect(Iterable tags) { - tagIds = tags.map((t) => t.id).toList(); - tagLabel = tags.map((t) => t.value).join(', '); + void handleOnSelect(Iterable value) { + tags = value.toSet(); } void handleClear() { tagCurrentFilterWidget.value = null; - search(filter.value.copyWith(tagIds: [])); + search(filter.value.copyWith(tags: const {})); } void handleApply() { - tagCurrentFilterWidget.value = tagLabel.isNotEmpty ? Text(tagLabel, style: context.textTheme.labelLarge) : null; - search(filter.value.copyWith(tagIds: tagIds)); + final label = tags.map((tag) => tag.value).join(', '); + tagCurrentFilterWidget.value = label.isNotEmpty ? Text(label, style: context.textTheme.labelLarge) : null; + search(filter.value.copyWith(tags: tags)); } unawaited( @@ -193,7 +193,7 @@ class SearchPage extends HookConsumerWidget { onClear: handleClear, child: TagPicker( onSelectExistingTag: handleOnSelect, - initialSelection: (filter.value.tagIds ?? []).toSet(), + initialSelection: tags.map((tag) => tag.id).toSet(), ), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart index e997c8c3f..ab6539246 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/dr import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/tag_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; @@ -51,6 +52,7 @@ class AssetDetails extends ConsumerWidget { TechnicalDetails(asset: asset, exifInfo: exifInfo), RatingDetails(exifInfo: exifInfo), AppearsInDetails(asset: asset), + TagDetails(asset: asset), SizedBox(height: context.padding.bottom + 48), ], ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/tag_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/tag_details.widget.dart new file mode 100644 index 000000000..e36714bbb --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/tag_details.widget.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/data/store.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/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/tag.action.dart'; +import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +const _collapsedTagsMaxHeight = 140.0; + +class TagDetails extends ConsumerStatefulWidget { + final BaseAsset asset; + + const TagDetails({super.key, required this.asset}); + + @override + ConsumerState createState() => _TagDetailsState(); +} + +class _TagDetailsState extends ConsumerState { + bool _isExpanded = false; + bool _hasOverflow = false; + + void _openTag(Tag tag) { + ref.invalidate(assetViewerProvider); + ref.invalidate(paginatedSearchProvider); + ref.read(searchPreFilterProvider.notifier) + ..clear() + ..setFilter( + .new( + tags: {tag}, + people: {}, + location: const .new(), + camera: const .new(), + date: const .new(), + display: const .new(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: const .new(), + mediaType: .other, + ), + ); + + unawaited(context.navigateTo(const SearchRoute())); + } + + bool _onTagsMetrics(ScrollMetricsNotification notification) { + final hasOverflow = + notification.metrics.maxScrollExtent + notification.metrics.viewportDimension > _collapsedTagsMaxHeight; + if (hasOverflow != _hasOverflow) { + setState(() => _hasOverflow = hasOverflow); + } + return false; + } + + @override + Widget build(BuildContext context) { + final asset = widget.asset; + final isTagsEnabled = ref.watch(Store.userMetadata.preferences()).valueOrNull?.tagsEnabled ?? false; + final user = ref.watch(currentUserProvider); + if (asset is! RemoteAsset || !isTagsEnabled || asset.ownerId != user?.id) { + return const SizedBox.shrink(); + } + + final tags = ref.watch(Store.tags.forAsset(asset.id)).valueOrNull ?? const []; + final tagAction = const TagAction(source: .viewer).create(context, ref); + final tagBackground = context.primaryColor.withAlpha(25); + final tagBorder = context.colorScheme.outlineVariant; + final tagText = context.colorScheme.onSurface; + final brandColor = context.primaryColor; + final chipTheme = context.themeData.copyWith( + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, + hoverColor: brandColor.withValues(alpha: 0.15), + ); + final isCollapsed = !_isExpanded; + + final tagWrap = Theme( + data: chipTheme, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final tag in tags) + InputChip( + onPressed: () => _openTag(tag), + tooltip: context.t.view_all, + label: ConstrainedBox( + constraints: BoxConstraints(maxWidth: context.width * 0.6), + child: Text(tag.value, overflow: TextOverflow.ellipsis), + ), + labelStyle: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w300), + backgroundColor: tagBackground, + shape: const StadiumBorder(), + side: BorderSide(color: tagBorder), + deleteIcon: Icon(Icons.close, size: 16, color: tagText), + onDeleted: switch (UntagAction(source: .viewer, tagId: tag.id).create(context, ref)) { + final action? => () async => await action.onAction(), + null => null, + }, + deleteButtonTooltipMessage: context.t.remove_tag, + ), + ], + ), + ); + + Widget tagArea = NotificationListener( + onNotification: _onTagsMetrics, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: isCollapsed ? _collapsedTagsMaxHeight : double.infinity), + child: SingleChildScrollView(physics: const NeverScrollableScrollPhysics(), child: tagWrap), + ), + ); + + if (isCollapsed && _hasOverflow) { + tagArea = ShaderMask( + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white, Colors.white, Colors.transparent], + stops: [0.0, 0.75, 1.0], + ).createShader(bounds), + blendMode: BlendMode.dstIn, + child: tagArea, + ); + } + + return Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + context.t.tags, + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + tagArea, + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (_hasOverflow) + ActionChip( + label: Text( + _isExpanded ? context.t.show_less : context.t.view_more, + style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), + ), + side: BorderSide.none, + shape: const StadiumBorder(), + backgroundColor: Colors.transparent, + onPressed: () => setState(() => _isExpanded = !_isExpanded), + ), + if (tagAction != null) + ActionChip( + avatar: Icon(Icons.new_label_outlined, size: 18, color: context.primaryColor), + label: Text(context.t.add_tag), + color: WidgetStateProperty.resolveWith( + (states) => + states.contains(WidgetState.hovered) ? brandColor.withValues(alpha: 0.15) : Colors.transparent, + ), + onPressed: tagAction.onAction, + ), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 25ff71333..1464a8d34 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction; import 'package:immich_mobile/domain/models/stack.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:immich_mobile/utils/option.dart'; @@ -66,6 +67,11 @@ class AssetApiRepository extends ApiRepository { return response.originalMimeType.orElse(null); } + Future> getAssetTags(String assetId) async { + final response = await checkNull(_api.getAssetInfo(assetId)); + return response.tags.orElse(null)?.map((tag) => Tag(id: tag.id, value: tag.value)).toList() ?? const []; + } + Future getChecksum(String id) async { return (await checkNull(_api.getAssetInfo(id))).checksum; } diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart index d19ec63cc..33cde4ef3 100644 --- a/mobile/lib/widgets/common/tag_picker.dart +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -86,7 +86,7 @@ class TagPicker extends HookConsumerWidget { final searchQuery = useState(''); final tags = ref.watch(Store.tags.all()); final selectedTagIds = useState>(initialSelection); - const borderRadius = BorderRadius.all(Radius.circular(10)); + const borderRadius = BorderRadius.all(Radius.circular(20)); final selectedNewTagValues = useState>({}); return Column( @@ -102,8 +102,8 @@ class TagPicker extends HookConsumerWidget { ), ), Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0), - child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1), + padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 8.0), + child: Divider(height: 1, color: context.colorScheme.surfaceContainerHighest, thickness: 1), ), Expanded( child: tags.widgetWhen( @@ -117,20 +117,23 @@ class TagPicker extends HookConsumerWidget { trimmedQuery.isNotEmpty && !tags.any((t) => t.value.toLowerCase() == trimmedQuery.toLowerCase()); final isCreateSelected = selectedNewTagValues.value.contains(trimmedQuery); - return ListView.builder( - 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: DecoratedBox( - decoration: BoxDecoration( - color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), + // ListTile paints its tile color on the nearest Material, clip it to the list + return Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + child: ListView.builder( + 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: ListTile( + shape: const RoundedRectangleBorder(borderRadius: borderRadius), + selected: isCreateSelected, + selectedTileColor: context.primaryColor, + tileColor: context.primaryColor.withAlpha(25), title: Text( trimmedQuery, style: context.textTheme.bodyLarge?.copyWith( @@ -152,20 +155,18 @@ class TagPicker extends HookConsumerWidget { onSelectNewTag!.call(newSelectedNewTagValues); }, ), - ), - ); - } - final tag = queryResult[index]; - final isSelected = selectedTagIds.value.any((id) => id == tag.id); + ); + } + final tag = queryResult[index]; + final isSelected = selectedTagIds.value.any((id) => id == tag.id); - return Padding( - padding: const EdgeInsets.only(bottom: 2.0), - child: DecoratedBox( - decoration: BoxDecoration( - color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25), - borderRadius: borderRadius, - ), + return Padding( + padding: const EdgeInsets.only(bottom: 7.0, right: 2.0, left: 2.0), child: ListTile( + shape: const RoundedRectangleBorder(borderRadius: borderRadius), + selected: isSelected, + selectedTileColor: context.primaryColor, + tileColor: context.primaryColor.withAlpha(25), title: Text( tag.value, style: context.textTheme.bodyLarge?.copyWith( @@ -183,9 +184,9 @@ class TagPicker extends HookConsumerWidget { onSelectExistingTag(tags.where((t) => newSelected.contains(t.id))); }, ), - ), - ); - }, + ); + }, + ), ); }, ), diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index c8a0808f0..ddd571e60 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -200,6 +200,7 @@ class ServiceMocks { void _stubTagApi() { when(tag.bulkTagAssets).thenAnswer((_) async => 0); + when(tag.untagAssets).thenAnswer((_) async => 0); when(tag.upsert).thenAnswer((_) async => const []); when(tag.getAll).thenAnswer((_) async => const []); } @@ -446,6 +447,9 @@ extension type const TagApiRepositoryStub(MockTagApiRepository repo) implements Future Function() get bulkTagAssets => () => repo.bulkTagAssets(any(), any()); + Future Function() get untagAssets => + () => repo.untagAssets(any(), any()); + Future> Function() get upsert => () => repo.upsert(any()); diff --git a/mobile/test/unit/presentation/actions/download_tag_action_test.dart b/mobile/test/unit/presentation/actions/download_tag_action_test.dart index 79e3191ad..58114429e 100644 --- a/mobile/test/unit/presentation/actions/download_tag_action_test.dart +++ b/mobile/test/unit/presentation/actions/download_tag_action_test.dart @@ -153,4 +153,44 @@ void main() { verifyNever(() => context.service.toast.success(any())); }); }); + + group('UntagAction', () { + List untagOverrides(Set selection) => [ + ...context.selected(selection), + toastServiceProvider.overrideWithValue(context.service.toast), + Store.userMetadata.preferences().overrideWith((ref) => Stream.value(const .new(tagsEnabled: true))), + ]; + + testWidgets('removes the tag from the owned assets and reports the count', (tester) async { + final asset = owned(); + when(context.service.tag.untagAssets).thenAnswer((_) async => 1); + + await tester.pumpTestAction( + context, + const UntagAction(source: .timeline, tagId: 'tag'), + overrides: untagOverrides({asset}), + ); + await tester.pumpAndSettle(); + + verify(() => tagApi.untagAssets('tag', [asset.id])).called(1); + + final message = verify(() => context.service.toast.success(captureAny())).captured.single as String; + expect(message, StaticTranslations.instance.removed_tagged_assets(count: 1)); + }); + + testWidgets('is hidden for assets owned by someone else', (tester) async { + final selection = {RemoteAssetFactory.create()}; + + await tester.pumpTestWidget( + context, + const ActionIconButton( + action: UntagAction(source: .timeline, tagId: 'tag'), + ), + overrides: untagOverrides(selection), + ); + + expect(find.byType(ImmichIconButton), findsNothing); + verifyNever(() => tagApi.untagAssets(any(), any())); + }); + }); }