feat(mobile): mobile tags

This commit is contained in:
Will 2026-08-13 18:20:28 -05:00
parent a55dc80a56
commit 039054dac7
10 changed files with 241 additions and 17 deletions

View file

@ -2247,6 +2247,7 @@
"view_details": "View Details",
"view_in_timeline": "View in timeline",
"view_link": "View link",
"view_more": "View more",
"view_name": "View",
"view_next_asset": "View next asset",
"view_previous_asset": "View previous asset",

View file

@ -21,6 +21,10 @@ class TagService {
return dtos.map((dto) => Tag.fromDto(dto)).toSet();
}
Future<void> untagAssets(String tagId, List<String> assetIds) async {
return _repository.untagAssets(tagId, assetIds);
}
Future<List<Tag>> upsertTags(List<String> tags) async {
final dtos = await _repository.upsertTags(tags);
if (dtos == null) {

View file

@ -20,6 +20,10 @@ class TagsApiRepository extends ApiRepository {
return response?.count ?? 0;
}
Future<void> untagAssets(String tagId, List<String> assetIds) async {
await _api.untagAssets(tagId, BulkIdsDto(ids: assetIds));
}
Future<List<TagResponseDto>?> upsertTags(List<String> tags) async {
return _api.upsertTags(TagUpsertDto(tags: tags));
}

View file

@ -42,19 +42,25 @@ class TagAction extends AssetActionBuilder {
Future<void> _tag(BuildContext context, WidgetRef ref, List<String> assetIds) async {
final clearSelection = ref.read(clearSelectionProvider(source));
if (await pickAndTagAssets(context, ref, assetIds)) {
clearSelection();
}
}
}
Future<bool> pickAndTagAssets(BuildContext context, WidgetRef ref, List<String> assetIds) async {
try {
final results = await showTagPickerModal(context: context);
if (results == null || !context.mounted) {
return;
return false;
}
final (selected, created) = results;
await tagAssets(context, ref, assetIds, selected: selected, created: created);
clearSelection();
return true;
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to tag the assets");
}
return false;
}
}
@ -80,7 +86,17 @@ Future<void> tagAssets(
final count = await tagService.bulkTagAssets(assetIds, tagIds.toList());
ref.invalidate(tagProvider);
ref.invalidate(assetTagsProvider);
if (context.mounted) {
toastService.success(context.t.tagged_assets(count: count));
}
}
Future<void> untagAsset(WidgetRef ref, String assetId, String tagId) async {
try {
await ref.read(tagServiceProvider).untagAssets(tagId, [assetId]);
ref.invalidate(assetTagsProvider);
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to remove the tag");
}
}

View file

@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/pages/search/paginated_search.provide
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
@ -103,7 +104,7 @@ class DriftSearchPage extends HookConsumerWidget {
}
unawaited(
Future.microtask(() {
Future.microtask(() async {
if (!context.mounted) {
return;
}
@ -120,6 +121,15 @@ class DriftSearchPage extends HookConsumerWidget {
? Text(preFilter.location.city!, style: context.textTheme.labelLarge)
: null;
search(preFilter);
final tagIds = preFilter.tagIds ?? const <String>[];
if (tagIds.isNotEmpty) {
final tags = await ref.read(tagProvider.future).catchError((_) => const <Tag>{});
final label = tags.where((tag) => tagIds.contains(tag.id)).map((tag) => tag.value).join(', ');
if (context.mounted) {
tagCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge);
}
}
}),
);

View file

@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/dr
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/tag_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
@ -47,6 +48,7 @@ class AssetDetails extends ConsumerWidget {
TechnicalDetails(asset: asset, exifInfo: exifInfo),
RatingDetails(exifInfo: exifInfo),
AppearsInDetails(asset: asset),
TagDetails(asset: asset),
SizedBox(height: context.padding.bottom + 48),
],
),

View file

@ -0,0 +1,174 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/tag.action.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
const _collapsedTagsMaxHeight = 140.0;
class TagDetails extends HookConsumerWidget {
final BaseAsset asset;
const TagDetails({super.key, required this.asset});
void _openTag(BuildContext context, WidgetRef ref, Tag tag) {
ref.invalidate(assetViewerProvider);
ref.invalidate(paginatedSearchProvider);
ref.read(searchPreFilterProvider.notifier)
..clear()
..setFilter(
.new(
tagIds: [tag.id],
people: {},
location: .new(),
camera: .new(),
date: .new(),
display: const .new(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: .new(),
mediaType: .other,
),
);
unawaited(context.navigateTo(const DriftSearchRoute()));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final isExpanded = useState(false);
final hasOverflow = useState(false);
final tagsController = useScrollController();
final asset = this.asset;
final isTagsEnabled = ref.watch(userMetadataPreferencesProvider).valueOrNull?.tagsEnabled ?? false;
final user = ref.watch(currentUserProvider);
if (asset is! RemoteAsset || !isTagsEnabled || asset.ownerId != user?.id) {
return const SizedBox.shrink();
}
final tags = ref.watch(assetTagsProvider(asset.id)).valueOrNull ?? const <Tag>[];
final tagBackground = context.primaryColor.withAlpha(25);
final tagBorder = context.colorScheme.outlineVariant;
final tagText = context.colorScheme.onSurface;
final brandColor = context.primaryColor;
final chipTheme = context.themeData.copyWith(
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
hoverColor: brandColor.withValues(alpha: 0.15),
);
final isCollapsed = !isExpanded.value;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!tagsController.hasClients || isExpanded.value) {
return;
}
final overflowing = tagsController.position.maxScrollExtent > 0;
if (overflowing != hasOverflow.value) {
hasOverflow.value = overflowing;
}
});
final tagWrap = Theme(
data: chipTheme,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in tags)
InputChip(
onPressed: () => _openTag(context, ref, tag),
tooltip: context.t.view_all,
label: ConstrainedBox(
constraints: BoxConstraints(maxWidth: context.width * 0.6),
child: Text(tag.value, overflow: TextOverflow.ellipsis),
),
labelStyle: TextStyle(color: tagText, fontSize: 14, fontWeight: FontWeight.w300),
backgroundColor: tagBackground,
shape: const StadiumBorder(),
side: BorderSide(color: tagBorder),
deleteIcon: Icon(Icons.close, size: 16, color: tagText),
onDeleted: () => unawaited(untagAsset(ref, asset.id, tag.id)),
deleteButtonTooltipMessage: context.t.remove_tag,
),
],
),
);
Widget tagArea = isCollapsed
? ConstrainedBox(
constraints: const BoxConstraints(maxHeight: _collapsedTagsMaxHeight),
child: SingleChildScrollView(
controller: tagsController,
physics: const NeverScrollableScrollPhysics(),
child: tagWrap,
),
)
: tagWrap;
if (isCollapsed && hasOverflow.value) {
tagArea = ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.white, Colors.transparent],
stops: [0.0, 0.75, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: tagArea,
);
}
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
context.t.tags,
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
tagArea,
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (hasOverflow.value)
ActionChip(
label: Text(
isExpanded.value ? context.t.show_less : context.t.view_more,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w500),
),
side: BorderSide.none,
shape: const StadiumBorder(),
backgroundColor: Colors.transparent,
onPressed: () => isExpanded.value = !isExpanded.value,
),
ActionChip(
avatar: Icon(Icons.new_label_outlined, size: 18, color: context.primaryColor),
label: Text(context.t.add_tag),
color: WidgetStateProperty.resolveWith(
(states) =>
states.contains(WidgetState.hovered) ? brandColor.withValues(alpha: 0.15) : Colors.transparent,
),
onPressed: () => unawaited(pickAndTagAssets(context, ref, [asset.id])),
),
],
),
],
),
);
}
}

View file

@ -3,6 +3,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
class TagNotifier extends AsyncNotifier<Set<Tag>> {
@override
@ -23,3 +24,7 @@ class TagNotifier extends AsyncNotifier<Set<Tag>> {
}
final tagProvider = AsyncNotifierProvider<TagNotifier, Set<Tag>>(TagNotifier.new);
final assetTagsProvider = FutureProvider.autoDispose.family<List<Tag>, String>(
(ref, assetId) => ref.watch(assetApiRepositoryProvider).getAssetTags(assetId),
);

View file

@ -3,6 +3,7 @@ import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
@ -76,6 +77,11 @@ class AssetApiRepository extends ApiRepository {
return response.originalMimeType.orElse(null);
}
Future<List<Tag>> getAssetTags(String assetId) async {
final response = await checkNull(_api.getAssetInfo(assetId));
return response.tags.orElse(null)?.map(Tag.fromDto).toList() ?? const [];
}
Future<void> updateDescription(String assetId, String description) {
return _api.updateAsset(assetId, UpdateAssetDto(description: Optional.present(description)));
}

View file

@ -86,7 +86,7 @@ class TagPicker extends HookConsumerWidget {
final searchQuery = useState('');
final tags = ref.watch(tagProvider);
final selectedTagIds = useState<Set<String>>(filter);
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(
@ -128,9 +128,10 @@ class TagPicker extends HookConsumerWidget {
child: DecoratedBox(
decoration: BoxDecoration(
color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25),
borderRadius: const BorderRadius.all(Radius.circular(10)),
borderRadius: borderRadius,
),
child: ListTile(
shape: const RoundedRectangleBorder(borderRadius: borderRadius),
title: Text(
trimmedQuery,
style: context.textTheme.bodyLarge?.copyWith(
@ -159,13 +160,14 @@ class TagPicker extends HookConsumerWidget {
final isSelected = selectedTagIds.value.any((id) => id == tag.id);
return Padding(
padding: const EdgeInsets.only(bottom: 2.0),
padding: const EdgeInsets.only(bottom: 7.0, right: 2.0, left: 2.0),
child: DecoratedBox(
decoration: BoxDecoration(
color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25),
borderRadius: borderRadius,
),
child: ListTile(
shape: const RoundedRectangleBorder(borderRadius: borderRadius),
title: Text(
tag.value,
style: context.textTheme.bodyLarge?.copyWith(