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:
@@ -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",
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user