From 290e325c5cd07e30fe484272cc55eda0a3d152f2 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 29 Jul 2025 16:17:33 -0500 Subject: [PATCH] feat: drift description editor (#20383) * feat: drift description editor * chore: use focus node * chore: code review fixes * chore: move description update to action.service * refactor * refactor --------- Co-authored-by: Alex --- i18n/en.json | 1 + .../repositories/remote_asset.repository.dart | 6 ++ .../asset_viewer/bottom_sheet.widget.dart | 78 +++++++++++++++++++ .../infrastructure/action.provider.dart | 16 ++++ .../repositories/asset_api.repository.dart | 4 + mobile/lib/services/action.service.dart | 8 ++ 6 files changed, 113 insertions(+) diff --git a/i18n/en.json b/i18n/en.json index 5f7f166222..524466f4e3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -988,6 +988,7 @@ }, "exif": "Exif", "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_description_error": "Error updating description", "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_people": "PEOPLE", diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 1ab62b3442..33735f1709 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -226,6 +226,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository { }); } + Future updateDescription(String assetId, String description) async { + await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write( + RemoteExifEntityCompanion(description: Value(description)), + ); + } + Future getCount() { return _db.managers.remoteAssetEntity.count(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 73ec6b456a..f27261a357 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -18,10 +18,12 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; const _kSeparator = ' • '; @@ -147,6 +149,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { title: _getDateTime(context, asset), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), + if (exifInfo != null) _SheetAssetDescription(exif: exifInfo), const SheetLocationDetails(), // Details header _SheetTile( @@ -234,3 +237,78 @@ class _SheetTile extends StatelessWidget { ); } } + +class _SheetAssetDescription extends ConsumerStatefulWidget { + final ExifInfo exif; + + const _SheetAssetDescription({required this.exif}); + + @override + ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); +} + +class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { + late TextEditingController _controller; + final _descriptionFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.exif.description ?? ''); + } + + Future saveDescription(String? previousDescription) async { + final newDescription = _controller.text.trim(); + + if (newDescription == previousDescription) { + _descriptionFocus.unfocus(); + return; + } + + final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); + + if (!editAction.success) { + _controller.text = previousDescription ?? ''; + + ImmichToast.show( + context: context, + msg: 'exif_bottom_sheet_description_error'.t(context: context), + toastType: ToastType.error, + ); + } + + _descriptionFocus.unfocus(); + } + + @override + Widget build(BuildContext context) { + // Watch the current asset EXIF provider to get updates + final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + // Update controller text when EXIF data changes + final currentDescription = currentExifInfo?.description ?? ''; + if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { + _controller.text = currentDescription; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: TextField( + controller: _controller, + keyboardType: TextInputType.multiline, + focusNode: _descriptionFocus, + maxLines: null, // makes it grow as text is added + decoration: InputDecoration( + hintText: 'exif_bottom_sheet_description'.t(context: context), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + ), + onTapOutside: (_) => saveDescription(currentExifInfo?.description), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 9d05a6ecab..69c0532303 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -250,6 +250,22 @@ class ActionNotifier extends Notifier { } } + Future updateDescription(ActionSource source, String description) async { + final ids = _getRemoteIdsForSource(source); + if (ids.length != 1) { + _logger.warning('updateDescription called with multiple assets, expected single asset'); + return ActionResult(count: ids.length, success: false, error: 'Expected single asset for description update'); + } + + try { + final isUpdated = await _service.updateDescription(ids.first, description); + return ActionResult(count: 1, success: isUpdated); + } catch (error, stack) { + _logger.severe('Failed to update description for asset', error, stack); + return ActionResult(count: 1, success: false, error: error.toString()); + } + } + Future stack(String userId, ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); try { diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 26147292d7..bbb176ffa7 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -93,6 +93,10 @@ class AssetApiRepository extends ApiRepository { // we need to get the MIME of the thumbnail once that gets added to the API return response.originalMimeType; } + + Future updateDescription(String assetId, String description) { + return _api.updateAsset(assetId, UpdateAssetDto(description: description)); + } } extension on StackResponseDto { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 5a23f16534..f45071e7f7 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -170,6 +170,14 @@ class ActionService { return removedCount; } + Future updateDescription(String assetId, String description) async { + // update remote first, then local to ensure consistency + await _assetApiRepository.updateDescription(assetId, description); + await _remoteAssetRepository.updateDescription(assetId, description); + + return true; + } + Future stack(String userId, List remoteIds) async { final stack = await _assetApiRepository.stack(remoteIds); await _remoteAssetRepository.stack(userId, stack);