mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
feat(mobile): mobile tags
This commit is contained in:
parent
a55dc80a56
commit
039054dac7
10 changed files with 241 additions and 17 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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])),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue