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 <alex.tran1502@gmail.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Santo Shakil <shakil.mezbah@gmail.com>
(cherry picked from commit 83c7ec50056bbefa10869c78457f8882c72275e2)
This commit is contained in:
Will
2026-10-14 21:24:13 +03:00
committed by Codex
parent 4fe7d42cfc
commit 1daf922555
14 changed files with 363 additions and 66 deletions
+1
View File
@@ -2339,6 +2339,7 @@
"view_group": "View group", "view_group": "View group",
"view_in_timeline": "View in timeline", "view_in_timeline": "View in timeline",
"view_link": "View link", "view_link": "View link",
"view_more": "View more",
"view_name": "View", "view_name": "View",
"view_next_asset": "View next asset", "view_next_asset": "View next asset",
"view_previous_asset": "View previous asset", "view_previous_asset": "View previous asset",
+6
View File
@@ -17,6 +17,12 @@ class TagApiRepository extends ApiRepository {
return response?.count ?? 0; return response?.count ?? 0;
} }
/// Remove the tag [tagId] from every asset in [assetIds], returning the number of assets successfully untagged
Future<int> untagAssets(String tagId, List<String> assetIds) async {
final response = await _api.untagAssets(tagId, BulkIdsDto(ids: assetIds));
return response?.where((result) => result.success).length ?? 0;
}
/// Retrieves all known tags /// Retrieves all known tags
Future<List<Tag>> getAll() async { Future<List<Tag>> getAll() async {
final response = await checkNull(_api.getAllTags()); final response = await checkNull(_api.getAllTags());
+21
View File
@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/tag.dart'; import 'package:immich_mobile/data/server/tag.dart';
import 'package:immich_mobile/data/store/util/cache.dart'; import 'package:immich_mobile/data/store/util/cache.dart';
import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
final _log = Logger("TagStore"); final _log = Logger("TagStore");
@@ -16,6 +17,12 @@ extension type const TagStore._(Provider<TagMutations> _provider) implements Pro
/// ///
/// **NOTE:** This is not reactive to changes, and only hits the HTTP API /// **NOTE:** This is not reactive to changes, and only hits the HTTP API
AutoDisposeFutureProvider<List<Tag>> all() => _allProvider; AutoDisposeFutureProvider<List<Tag>> 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<List<Tag>, String> get forAsset => _forAssetProvider;
} }
final _allProvider = FutureProvider.autoDispose<List<Tag>>((ref) async { final _allProvider = FutureProvider.autoDispose<List<Tag>>((ref) async {
@@ -27,6 +34,10 @@ final _allProvider = FutureProvider.autoDispose<List<Tag>>((ref) async {
} }
}); });
final _forAssetProvider = FutureProvider.autoDispose.family<List<Tag>, String>(
(ref, assetId) => ref.watch(assetServiceProvider).getTags(assetId),
);
class TagMutations extends StoreMutations { class TagMutations extends StoreMutations {
const TagMutations._(super.ref); const TagMutations._(super.ref);
@@ -49,4 +60,14 @@ class TagMutations extends StoreMutations {
rethrow; rethrow;
} }
} }
/// Remove the tag [tagId] from every asset in [assetIds], returning the number of assets successfully untagged
Future<int> removeFromAssets(String tagId, List<String> assetIds) async {
try {
return await read(tagApiRepositoryProvider).untagAssets(tagId, assetIds);
} catch (error, stack) {
_log.severe("Failed to untag assets", error, stack);
rethrow;
}
}
} }
@@ -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/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.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/store.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -198,6 +199,10 @@ class AssetService {
return _localRepository.get(id); return _localRepository.get(id);
} }
Future<List<Tag>> getTags(String assetId) {
return _apiRepository.getAssetTags(assetId);
}
Future<void> stackEditedUpload(String localId, String remoteId, String? checksum) async { Future<void> 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 // 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); final uploadedChecksum = checksum ?? await _apiRepository.getChecksum(remoteId);
@@ -17,6 +17,10 @@ class SearchApiRepository extends ApiRepository {
type = AssetTypeEnum.VIDEO; type = AssetTypeEnum.VIDEO;
} }
final tagIds = filter.tags == null
? const Optional<List<String>>.absent()
: Optional.present(filter.tags!.map((tag) => tag.id).toList());
if ((filter.context != null && filter.context!.isNotEmpty) || if ((filter.context != null && filter.context!.isNotEmpty) ||
(filter.assetId != null && filter.assetId!.isNotEmpty)) { (filter.assetId != null && filter.assetId!.isNotEmpty)) {
return _api.searchSmart( return _api.searchSmart(
@@ -42,7 +46,7 @@ class SearchApiRepository extends ApiRepository {
isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(), isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
isNotInAlbum: filter.display.isNotInAlbum ? 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()), 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), type: type == null ? const Optional.absent() : Optional.present(type),
page: Optional.present(page), page: Optional.present(page),
size: const Optional.present(100), size: const Optional.present(100),
@@ -73,7 +77,7 @@ class SearchApiRepository extends ApiRepository {
isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(), isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
isNotInAlbum: filter.display.isNotInAlbum ? 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()), 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), type: type == null ? const Optional.absent() : Optional.present(type),
page: Optional.present(page), page: Optional.present(page),
size: const Optional.present(1000), size: const Optional.present(1000),
@@ -1,6 +1,7 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/person.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/utils/option.dart'; import 'package:immich_mobile/utils/option.dart';
part 'search_filter.model.freezed.dart'; part 'search_filter.model.freezed.dart';
@@ -52,7 +53,7 @@ abstract class SearchFilter with _$SearchFilter {
String? ocr, String? ocr,
String? language, String? language,
String? assetId, String? assetId,
List<String>? tagIds, Set<Tag>? tags,
required Set<Person> people, required Set<Person> people,
required SearchLocationFilter location, required SearchLocationFilter location,
required SearchCameraFilter camera, required SearchCameraFilter camera,
@@ -68,7 +69,7 @@ abstract class SearchFilter with _$SearchFilter {
(description == null || (description!.isEmpty)) && (description == null || (description!.isEmpty)) &&
(assetId == null || (assetId!.isEmpty)) && (assetId == null || (assetId!.isEmpty)) &&
(ocr == null || (ocr!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) &&
(tagIds ?? []).isEmpty && (tags?.isEmpty ?? true) &&
people.isEmpty && people.isEmpty &&
location.country == null && location.country == null &&
location.state == null && location.state == null &&
@@ -40,7 +40,6 @@ class TagAction extends AssetActionBuilder {
Future<void> _tag(BuildContext context, WidgetRef ref, List<String> assetIds) async { Future<void> _tag(BuildContext context, WidgetRef ref, List<String> assetIds) async {
final clearSelection = ref.read(clearSelectionProvider(source)); final clearSelection = ref.read(clearSelectionProvider(source));
try { try {
final results = await showTagPickerModal(context: context); final results = await showTagPickerModal(context: context);
if (results == null || !context.mounted) { if (results == null || !context.mounted) {
@@ -77,7 +76,36 @@ Future<void> tagAssets(
} }
final count = await ref.read(Store.tags).applyToAssets(assetIds, tagIds.toList()); final count = await ref.read(Store.tags).applyToAssets(assetIds, tagIds.toList());
ref.invalidate(Store.tags.forAsset);
if (context.mounted) { if (context.mounted) {
toastService.success(context.t.tagged_assets(count: count)); 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<void> _untag(BuildContext context, WidgetRef ref, List<String> 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");
}
}
}
@@ -59,7 +59,7 @@ class SearchPage extends HookConsumerWidget {
rating: const SearchRatingFilter(), rating: const SearchRatingFilter(),
mediaType: AssetType.other, mediaType: AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}", 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)); 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 <Tag>{}).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. // TODO: Use ref.listen with `fireImmediately` in the new riverpod version.
final preFilter = ref.watch(searchPreFilterProvider); final preFilter = ref.watch(searchPreFilterProvider);
useEffect(() { useEffect(() {
@@ -101,26 +121,7 @@ class SearchPage extends HookConsumerWidget {
return null; return null;
} }
unawaited( unawaited(Future.microtask(() => applyPreFilter(preFilter)));
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);
}),
);
return null; return null;
}, [preFilter]); }, [preFilter]);
@@ -162,22 +163,21 @@ class SearchPage extends HookConsumerWidget {
} }
void showTagPicker() { void showTagPicker() {
var tagIds = filter.value.tagIds ?? []; var tags = filter.value.tags ?? const <Tag>{};
String tagLabel = '';
void handleOnSelect(Iterable<Tag> tags) { void handleOnSelect(Iterable<Tag> value) {
tagIds = tags.map((t) => t.id).toList(); tags = value.toSet();
tagLabel = tags.map((t) => t.value).join(', ');
} }
void handleClear() { void handleClear() {
tagCurrentFilterWidget.value = null; tagCurrentFilterWidget.value = null;
search(filter.value.copyWith(tagIds: [])); search(filter.value.copyWith(tags: const {}));
} }
void handleApply() { void handleApply() {
tagCurrentFilterWidget.value = tagLabel.isNotEmpty ? Text(tagLabel, style: context.textTheme.labelLarge) : null; final label = tags.map((tag) => tag.value).join(', ');
search(filter.value.copyWith(tagIds: tagIds)); tagCurrentFilterWidget.value = label.isNotEmpty ? Text(label, style: context.textTheme.labelLarge) : null;
search(filter.value.copyWith(tags: tags));
} }
unawaited( unawaited(
@@ -193,7 +193,7 @@ class SearchPage extends HookConsumerWidget {
onClear: handleClear, onClear: handleClear,
child: TagPicker( child: TagPicker(
onSelectExistingTag: handleOnSelect, onSelectExistingTag: handleOnSelect,
initialSelection: (filter.value.tagIds ?? []).toSet(), initialSelection: tags.map((tag) => tag.id).toSet(),
), ),
), ),
), ),
@@ -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/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/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/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/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.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), TechnicalDetails(asset: asset, exifInfo: exifInfo),
RatingDetails(exifInfo: exifInfo), RatingDetails(exifInfo: exifInfo),
AppearsInDetails(asset: asset), AppearsInDetails(asset: asset),
TagDetails(asset: asset),
SizedBox(height: context.padding.bottom + 48), SizedBox(height: context.padding.bottom + 48),
], ],
), ),
@@ -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<TagDetails> {
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 <Tag>[];
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<ScrollMetricsNotification>(
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,
),
],
),
],
),
);
}
}
@@ -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/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction; 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/stack.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/utils/option.dart'; import 'package:immich_mobile/utils/option.dart';
@@ -66,6 +67,11 @@ class AssetApiRepository extends ApiRepository {
return response.originalMimeType.orElse(null); return response.originalMimeType.orElse(null);
} }
Future<List<Tag>> 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<String> getChecksum(String id) async { Future<String> getChecksum(String id) async {
return (await checkNull(_api.getAssetInfo(id))).checksum; return (await checkNull(_api.getAssetInfo(id))).checksum;
} }
+32 -31
View File
@@ -86,7 +86,7 @@ class TagPicker extends HookConsumerWidget {
final searchQuery = useState(''); final searchQuery = useState('');
final tags = ref.watch(Store.tags.all()); final tags = ref.watch(Store.tags.all());
final selectedTagIds = useState<Set<String>>(initialSelection); final selectedTagIds = useState<Set<String>>(initialSelection);
const borderRadius = BorderRadius.all(Radius.circular(10)); const borderRadius = BorderRadius.all(Radius.circular(20));
final selectedNewTagValues = useState<Set<String>>({}); final selectedNewTagValues = useState<Set<String>>({});
return Column( return Column(
@@ -102,8 +102,8 @@ class TagPicker extends HookConsumerWidget {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0), padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 8.0),
child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1), child: Divider(height: 1, color: context.colorScheme.surfaceContainerHighest, thickness: 1),
), ),
Expanded( Expanded(
child: tags.widgetWhen( child: tags.widgetWhen(
@@ -117,20 +117,23 @@ class TagPicker extends HookConsumerWidget {
trimmedQuery.isNotEmpty && trimmedQuery.isNotEmpty &&
!tags.any((t) => t.value.toLowerCase() == trimmedQuery.toLowerCase()); !tags.any((t) => t.value.toLowerCase() == trimmedQuery.toLowerCase());
final isCreateSelected = selectedNewTagValues.value.contains(trimmedQuery); final isCreateSelected = selectedNewTagValues.value.contains(trimmedQuery);
return ListView.builder( // ListTile paints its tile color on the nearest Material, clip it to the list
itemCount: queryResult.length + (showCreateTile ? 1 : 0), return Material(
padding: const EdgeInsets.all(8), type: MaterialType.transparency,
itemBuilder: (context, index) { clipBehavior: Clip.hardEdge,
if (showCreateTile && index == queryResult.length) { child: ListView.builder(
// Create new tag tile itemCount: queryResult.length + (showCreateTile ? 1 : 0),
return Padding( padding: const EdgeInsets.all(8),
padding: const EdgeInsets.only(bottom: 2.0), itemBuilder: (context, index) {
child: DecoratedBox( if (showCreateTile && index == queryResult.length) {
decoration: BoxDecoration( // Create new tag tile
color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25), return Padding(
borderRadius: const BorderRadius.all(Radius.circular(10)), padding: const EdgeInsets.only(bottom: 2.0),
),
child: ListTile( child: ListTile(
shape: const RoundedRectangleBorder(borderRadius: borderRadius),
selected: isCreateSelected,
selectedTileColor: context.primaryColor,
tileColor: context.primaryColor.withAlpha(25),
title: Text( title: Text(
trimmedQuery, trimmedQuery,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
@@ -152,20 +155,18 @@ class TagPicker extends HookConsumerWidget {
onSelectNewTag!.call(newSelectedNewTagValues); onSelectNewTag!.call(newSelectedNewTagValues);
}, },
), ),
), );
); }
} final tag = queryResult[index];
final tag = queryResult[index]; final isSelected = selectedTagIds.value.any((id) => id == tag.id);
final isSelected = selectedTagIds.value.any((id) => id == tag.id);
return Padding( 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( child: ListTile(
shape: const RoundedRectangleBorder(borderRadius: borderRadius),
selected: isSelected,
selectedTileColor: context.primaryColor,
tileColor: context.primaryColor.withAlpha(25),
title: Text( title: Text(
tag.value, tag.value,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
@@ -183,9 +184,9 @@ class TagPicker extends HookConsumerWidget {
onSelectExistingTag(tags.where((t) => newSelected.contains(t.id))); onSelectExistingTag(tags.where((t) => newSelected.contains(t.id)));
}, },
), ),
), );
); },
}, ),
); );
}, },
), ),
+4
View File
@@ -200,6 +200,7 @@ class ServiceMocks {
void _stubTagApi() { void _stubTagApi() {
when(tag.bulkTagAssets).thenAnswer((_) async => 0); when(tag.bulkTagAssets).thenAnswer((_) async => 0);
when(tag.untagAssets).thenAnswer((_) async => 0);
when(tag.upsert).thenAnswer((_) async => const []); when(tag.upsert).thenAnswer((_) async => const []);
when(tag.getAll).thenAnswer((_) async => const []); when(tag.getAll).thenAnswer((_) async => const []);
} }
@@ -446,6 +447,9 @@ extension type const TagApiRepositoryStub(MockTagApiRepository repo) implements
Future<int> Function() get bulkTagAssets => Future<int> Function() get bulkTagAssets =>
() => repo.bulkTagAssets(any(), any()); () => repo.bulkTagAssets(any(), any());
Future<int> Function() get untagAssets =>
() => repo.untagAssets(any(), any());
Future<List<Tag>> Function() get upsert => Future<List<Tag>> Function() get upsert =>
() => repo.upsert(any()); () => repo.upsert(any());
@@ -153,4 +153,44 @@ void main() {
verifyNever(() => context.service.toast.success(any())); verifyNever(() => context.service.toast.success(any()));
}); });
}); });
group('UntagAction', () {
List<Override> untagOverrides(Set<BaseAsset> 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()));
});
});
} }