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:
@@ -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<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
|
||||
Future<List<Tag>> getAll() async {
|
||||
final response = await checkNull(_api.getAllTags());
|
||||
|
||||
@@ -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<TagMutations> _provider) implements Pro
|
||||
///
|
||||
/// **NOTE:** This is not reactive to changes, and only hits the HTTP API
|
||||
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 {
|
||||
@@ -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 {
|
||||
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<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/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<List<Tag>> getTags(String assetId) {
|
||||
return _apiRepository.getAssetTags(assetId);
|
||||
}
|
||||
|
||||
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
|
||||
final uploadedChecksum = checksum ?? await _apiRepository.getChecksum(remoteId);
|
||||
|
||||
@@ -17,6 +17,10 @@ class SearchApiRepository extends ApiRepository {
|
||||
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) ||
|
||||
(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),
|
||||
|
||||
@@ -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<String>? tagIds,
|
||||
Set<Tag>? tags,
|
||||
required Set<Person> 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 &&
|
||||
|
||||
@@ -40,7 +40,6 @@ class TagAction extends AssetActionBuilder {
|
||||
|
||||
Future<void> _tag(BuildContext context, WidgetRef ref, List<String> 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<void> 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<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(),
|
||||
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 <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.
|
||||
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 <Tag>{};
|
||||
|
||||
void handleOnSelect(Iterable<Tag> tags) {
|
||||
tagIds = tags.map((t) => t.id).toList();
|
||||
tagLabel = tags.map((t) => t.value).join(', ');
|
||||
void handleOnSelect(Iterable<Tag> 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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_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<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 {
|
||||
return (await checkNull(_api.getAssetInfo(id))).checksum;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class TagPicker extends HookConsumerWidget {
|
||||
final searchQuery = useState('');
|
||||
final tags = ref.watch(Store.tags.all());
|
||||
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>>({});
|
||||
|
||||
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)));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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<int> Function() get bulkTagAssets =>
|
||||
() => repo.bulkTagAssets(any(), any());
|
||||
|
||||
Future<int> Function() get untagAssets =>
|
||||
() => repo.untagAssets(any(), any());
|
||||
|
||||
Future<List<Tag>> Function() get upsert =>
|
||||
() => repo.upsert(any());
|
||||
|
||||
|
||||
@@ -153,4 +153,44 @@ void main() {
|
||||
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()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user