From 039054dac7ac1854f29799cc42dd316cef14d372 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 13 Aug 2026 18:20:28 -0500 Subject: [PATCH] feat(mobile): mobile tags --- i18n/en.json | 1 + mobile/lib/domain/services/tag.service.dart | 4 + .../repositories/tags_api.repository.dart | 4 + .../lib/presentation/actions/tag.action.dart | 38 ++-- .../pages/search/drift_search.page.dart | 12 +- .../asset_viewer/asset_details.widget.dart | 2 + .../asset_details/tag_details.widget.dart | 174 ++++++++++++++++++ .../infrastructure/tag.provider.dart | 5 + .../repositories/asset_api.repository.dart | 6 + mobile/lib/widgets/common/tag_picker.dart | 12 +- 10 files changed, 241 insertions(+), 17 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 536b7677ac..a19b5ab166 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2247,6 +2247,7 @@ "view_details": "View Details", "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/domain/services/tag.service.dart b/mobile/lib/domain/services/tag.service.dart index 6eeb83d4cb..d37ea1e91d 100644 --- a/mobile/lib/domain/services/tag.service.dart +++ b/mobile/lib/domain/services/tag.service.dart @@ -21,6 +21,10 @@ class TagService { return dtos.map((dto) => Tag.fromDto(dto)).toSet(); } + Future untagAssets(String tagId, List assetIds) async { + return _repository.untagAssets(tagId, assetIds); + } + Future> upsertTags(List tags) async { final dtos = await _repository.upsertTags(tags); if (dtos == null) { diff --git a/mobile/lib/infrastructure/repositories/tags_api.repository.dart b/mobile/lib/infrastructure/repositories/tags_api.repository.dart index 5963fc2f23..77592c1dd8 100644 --- a/mobile/lib/infrastructure/repositories/tags_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/tags_api.repository.dart @@ -20,6 +20,10 @@ class TagsApiRepository extends ApiRepository { return response?.count ?? 0; } + Future untagAssets(String tagId, List assetIds) async { + await _api.untagAssets(tagId, BulkIdsDto(ids: assetIds)); + } + Future?> upsertTags(List tags) async { return _api.upsertTags(TagUpsertDto(tags: tags)); } diff --git a/mobile/lib/presentation/actions/tag.action.dart b/mobile/lib/presentation/actions/tag.action.dart index 978648708f..700838b4a9 100644 --- a/mobile/lib/presentation/actions/tag.action.dart +++ b/mobile/lib/presentation/actions/tag.action.dart @@ -42,22 +42,28 @@ 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) { - return; - } - - final (selected, created) = results; - await tagAssets(context, ref, assetIds, selected: selected, created: created); + if (await pickAndTagAssets(context, ref, assetIds)) { clearSelection(); - } catch (error, stack) { - handleError(error, stack: stack, description: "Failed to tag the assets"); } } } +Future pickAndTagAssets(BuildContext context, WidgetRef ref, List assetIds) async { + try { + final results = await showTagPickerModal(context: context); + if (results == null || !context.mounted) { + return false; + } + + final (selected, created) = results; + await tagAssets(context, ref, assetIds, selected: selected, created: created); + return true; + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to tag the assets"); + return false; + } +} + @visibleForTesting Future tagAssets( BuildContext context, @@ -80,7 +86,17 @@ Future tagAssets( final count = await tagService.bulkTagAssets(assetIds, tagIds.toList()); ref.invalidate(tagProvider); + ref.invalidate(assetTagsProvider); if (context.mounted) { toastService.success(context.t.tagged_assets(count: count)); } } + +Future untagAsset(WidgetRef ref, String assetId, String tagId) async { + try { + await ref.read(tagServiceProvider).untagAssets(tagId, [assetId]); + ref.invalidate(assetTagsProvider); + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to remove the tag"); + } +} diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0c6b0d8ae4..95d502614d 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/pages/search/paginated_search.provide import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; @@ -103,7 +104,7 @@ class DriftSearchPage extends HookConsumerWidget { } unawaited( - Future.microtask(() { + Future.microtask(() async { if (!context.mounted) { return; } @@ -120,6 +121,15 @@ class DriftSearchPage extends HookConsumerWidget { ? Text(preFilter.location.city!, style: context.textTheme.labelLarge) : null; search(preFilter); + + final tagIds = preFilter.tagIds ?? const []; + if (tagIds.isNotEmpty) { + final tags = await ref.read(tagProvider.future).catchError((_) => const {}); + final label = tags.where((tag) => tagIds.contains(tag.id)).map((tag) => tag.value).join(', '); + if (context.mounted) { + tagCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge); + } + } }), ); 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 4b59e1ae60..f98c5bd9d3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -8,6 +8,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'; @@ -47,6 +48,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 0000000000..fe73006c0b --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/tag_details.widget.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.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/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/infrastructure/tag.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +const _collapsedTagsMaxHeight = 140.0; + +class TagDetails extends HookConsumerWidget { + final BaseAsset asset; + + const TagDetails({super.key, required this.asset}); + + void _openTag(BuildContext context, WidgetRef ref, Tag tag) { + ref.invalidate(assetViewerProvider); + ref.invalidate(paginatedSearchProvider); + ref.read(searchPreFilterProvider.notifier) + ..clear() + ..setFilter( + .new( + tagIds: [tag.id], + people: {}, + location: .new(), + camera: .new(), + date: .new(), + display: const .new(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: .new(), + mediaType: .other, + ), + ); + + unawaited(context.navigateTo(const DriftSearchRoute())); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isExpanded = useState(false); + final hasOverflow = useState(false); + final tagsController = useScrollController(); + final asset = this.asset; + final isTagsEnabled = ref.watch(userMetadataPreferencesProvider).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(assetTagsProvider(asset.id)).valueOrNull ?? const []; + 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.value; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!tagsController.hasClients || isExpanded.value) { + return; + } + final overflowing = tagsController.position.maxScrollExtent > 0; + if (overflowing != hasOverflow.value) { + hasOverflow.value = overflowing; + } + }); + + final tagWrap = Theme( + data: chipTheme, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final tag in tags) + InputChip( + onPressed: () => _openTag(context, ref, tag), + tooltip: context.t.view_all, + label: ConstrainedBox( + constraints: BoxConstraints(maxWidth: context.width * 0.6), + child: Text(tag.value, overflow: TextOverflow.ellipsis), + ), + labelStyle: TextStyle(color: tagText, fontSize: 14, fontWeight: FontWeight.w300), + backgroundColor: tagBackground, + shape: const StadiumBorder(), + side: BorderSide(color: tagBorder), + deleteIcon: Icon(Icons.close, size: 16, color: tagText), + onDeleted: () => unawaited(untagAsset(ref, asset.id, tag.id)), + deleteButtonTooltipMessage: context.t.remove_tag, + ), + ], + ), + ); + + Widget tagArea = isCollapsed + ? ConstrainedBox( + constraints: const BoxConstraints(maxHeight: _collapsedTagsMaxHeight), + child: SingleChildScrollView( + controller: tagsController, + physics: const NeverScrollableScrollPhysics(), + child: tagWrap, + ), + ) + : tagWrap; + + if (isCollapsed && hasOverflow.value) { + 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.value) + ActionChip( + label: Text( + isExpanded.value ? context.t.show_less : context.t.view_more, + style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w500), + ), + side: BorderSide.none, + shape: const StadiumBorder(), + backgroundColor: Colors.transparent, + onPressed: () => isExpanded.value = !isExpanded.value, + ), + 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: () => unawaited(pickAndTagAssets(context, ref, [asset.id])), + ), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart index 152582aa93..060db9a069 100644 --- a/mobile/lib/providers/infrastructure/tag.provider.dart +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/services/tag.service.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; class TagNotifier extends AsyncNotifier> { @override @@ -23,3 +24,7 @@ class TagNotifier extends AsyncNotifier> { } final tagProvider = AsyncNotifierProvider>(TagNotifier.new); + +final assetTagsProvider = FutureProvider.autoDispose.family, String>( + (ref, assetId) => ref.watch(assetApiRepositoryProvider).getAssetTags(assetId), +); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 9b92b491bf..de0c868e07 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -3,6 +3,7 @@ import 'package:http/http.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'; @@ -76,6 +77,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.fromDto).toList() ?? const []; + } + Future updateDescription(String assetId, String description) { return _api.updateAsset(assetId, UpdateAssetDto(description: Optional.present(description))); } diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart index 2b4f07536f..88082ed829 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(tagProvider); final selectedTagIds = useState>(filter); - 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( @@ -128,9 +128,10 @@ class TagPicker extends HookConsumerWidget { child: DecoratedBox( decoration: BoxDecoration( color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25), - borderRadius: const BorderRadius.all(Radius.circular(10)), + borderRadius: borderRadius, ), child: ListTile( + shape: const RoundedRectangleBorder(borderRadius: borderRadius), title: Text( trimmedQuery, style: context.textTheme.bodyLarge?.copyWith( @@ -159,13 +160,14 @@ class TagPicker extends HookConsumerWidget { final isSelected = selectedTagIds.value.any((id) => id == tag.id); return Padding( - padding: const EdgeInsets.only(bottom: 2.0), + padding: const EdgeInsets.only(bottom: 7.0, right: 2.0, left: 2.0), child: DecoratedBox( decoration: BoxDecoration( color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25), borderRadius: borderRadius, ), child: ListTile( + shape: const RoundedRectangleBorder(borderRadius: borderRadius), title: Text( tag.value, style: context.textTheme.bodyLarge?.copyWith(