From 83afd49f5c3a11f0845a4361815471e86dfe3e13 Mon Sep 17 00:00:00 2001 From: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Date: Wed, 2 Jul 2025 00:52:11 +0800 Subject: [PATCH] feat(mobile): edit location action (#19645) * change dto from integer to double * feat(mobile): edit location action * patch openapi * refactor in provider * fix lint * chore: not showing success prompt if dimissed * i18n --------- Co-authored-by: Alex --- i18n/en.json | 1 + .../drift_schemas/main/drift_schema_v1.json | Bin 21740 -> 21752 bytes .../infrastructure/entities/exif.entity.dart | 8 +-- .../entities/exif.entity.drift.dart | Bin 60139 -> 60382 bytes .../repositories/exif.repository.dart | 35 ++++++++++++ .../repositories/remote_asset.repository.dart | 27 +++++++--- .../edit_location_action_button.widget.dart | 40 +++++++++++++- .../home_bottom_app_bar.widget.dart | 2 +- .../infrastructure/action.provider.dart | 22 ++++++++ .../infrastructure/asset.provider.dart | 5 ++ .../infrastructure/exif.provider.dart | 4 ++ .../repositories/asset_api.repository.dart | 14 +++++ mobile/lib/services/action.service.dart | 51 ++++++++++++++++-- .../openapi/lib/model/sync_asset_exif_v1.dart | Bin 11859 -> 11944 bytes open-api/immich-openapi-specs.json | 15 ++++-- .../native/native_class.mustache | 2 +- .../native/native_class.mustache.patch | 6 +-- server/src/dtos/sync.dto.ts | 10 ++-- 18 files changed, 211 insertions(+), 31 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 3bd31638e..d81a6270a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -799,6 +799,7 @@ "edit_key": "Edit key", "edit_link": "Edit link", "edit_location": "Edit location", + "edit_location_action_prompt": "{count} location edited", "edit_location_dialog_title": "Location", "edit_name": "Edit name", "edit_people": "Edit people", diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 3663d35b5691ddd9d3652e8db754c34309d9b0c3..096128e526e39f6e14705e7ea4e54e3bc6eacf17 100644 GIT binary patch delta 70 xcmaE}lJUn%#tr&bY$^GrNja&L7qXO1USahRnagBjfXrQOV}!)r9A-OB8~_Dp90ULW delta 42 pcmeydlJU(-#tr&bll3hsC$F}82&Oq~48Zgj8zT_CImvdKH~@+a5i$S( diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index 11730b776..c78643d89 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -116,15 +116,15 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin { TextColumn get exposureTime => text().nullable()(); - IntColumn get fNumber => integer().nullable()(); + RealColumn get fNumber => real().nullable()(); IntColumn get fileSize => integer().nullable()(); - IntColumn get focalLength => integer().nullable()(); + RealColumn get focalLength => real().nullable()(); - IntColumn get latitude => integer().nullable()(); + RealColumn get latitude => real().nullable()(); - IntColumn get longitude => integer().nullable()(); + RealColumn get longitude => real().nullable()(); IntColumn get iso => integer().nullable()(); diff --git a/mobile/lib/infrastructure/entities/exif.entity.drift.dart b/mobile/lib/infrastructure/entities/exif.entity.drift.dart index 10025d9cb876ce60d9866804303fa33b84d8dcfb..a8fd3a4477f9047fa9d9c4be42e18e97b5df963e 100644 GIT binary patch delta 1208 zcmb7EUr1AN6vn2PxzZ^*Q)fAsvng0px0jZ81?#W7)}D-J<)-`VbQBY^9_lgEL_9@g z51|h&tZ4b8m+-+58WD*NEQS>1#E2fsw;r3@{Vtb!h~B<)zVAEdcYf#I-}e>8=Z}iG zM#1$(hCE>}troNbmT+3YeOzTxPGIA8;mDw$E-ET1z`bM_@`v-F`VTJ)aKE<0x1o2_ z=`s^t-P27A#SKVzm`_q?y+NKTEgu1EG90B_mI{jN3p3{ZtVJ_dBywy+uayp!t8xmq z((+y!uWN$RWT-C&)z!8M*kQf~_|AM8;^P)hjkME#1yC*60G+}?h$jTYHWodmEs(f> zJDRH?UhvfduKDbMhkNX7z<(pJEVm0@=Zo~Z!w&DDKN_IXNR3j=8)-c~}1tIJZuf?qA$3#Us_UcymP_z8F0( z4^3k(S;!uZk#tM^WD}q3Ym-lBRq1rT;GjFK11XN+{fnXJ2@RW@T;?#X(bqXN;PGCf z>v#1OzZXO6vr~SIXhgNF=I7J_QNx<=w{XzAr-uPQO}7K?nz2IcnxTB8Hy;K8&&4c& zcVcZ2zm2U+Kb_NVqMq48TJGS;kxqLEZ(p~O(_ y2Mx`ql+weItwujm6{z$z^-77ZkJsh_L+Nh7H)+?7TtVKLeRh`8HSEOt4EGzUd(Zj+ delta 950 zcmb`G-%FEG7{^)f_RdYGxs_>KbD2Uvx2dlvy6l&x7iwNbCCgrQ&bc{BCKXBE7|JxF zeeuTCMH6zAtKdiw6j)tMj4WIcj?hBwqAsHUpmPq2R#!n+pXWT^=Xv0q=X(}E3g15n zkGBhg#~*-EC~{C9skF3rx5~acm){-eIHDR zMe$#z5n0@khBsR^WI(S|umFQn6?CI&^yiyl%d{bp?_?zLZAa75xLbn(eG}{j`+2O9 zQZc!wkH;I0wfvmOALOKsXZL!tC_``ySWDI(J)v*~C) zB?48KI1SYfPHW7H=@Xgo$84OFv3;bm;i-Z=t1+tnq2on%NK&cW8+yB1Ts^Q~$O=hH zd~Vg?q`MqeR}GnU4=HF#>ZaXnE(?10hPs@{>g=FJ0$mXvdX4KdraJiylzBU;iu>N& zb!EX@!Ka`1y)q*_Y{v27_nf68P8Xdem zU-ntJvhI@K(!_?ZBKpTfn8)5SYB!t)`A#14!eunALQA-fk<5i{A{-GNbdhe(kw`K6 z?xtZbQp89y#)C?{n^0qX!o{hN7E+3+i`p5<_2?I2Z4ZaA^dJM~dMl@)x{%WvD`h(I z$FA<=&ixUu%>3&g;9@)n&KV=CAlGKjXmK+kVrl*`XF(#Dvp!*BBt41OBASyas7=!0 zlv_sixSiC{SdB-?97Zyqe4fJIKwMk;vE^($Sq`%l&aIqf_veeWteTKqDd+EDT74=0 E0xzC+8UO$Q diff --git a/mobile/lib/infrastructure/repositories/exif.repository.dart b/mobile/lib/infrastructure/repositories/exif.repository.dart index 0012e329c..d25572fda 100644 --- a/mobile/lib/infrastructure/repositories/exif.repository.dart +++ b/mobile/lib/infrastructure/repositories/exif.repository.dart @@ -1,6 +1,8 @@ +import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' as entity; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:isar/isar.dart'; @@ -41,3 +43,36 @@ class IsarExifRepository extends IsarDatabaseRepository { }); } } + +class DriftRemoteExifRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftRemoteExifRepository(this._db) : super(_db); + + Future get(String assetId) { + final query = _db.remoteExifEntity.select() + ..where((exif) => exif.assetId.equals(assetId)); + + return query.map((asset) => asset.toDto()).getSingleOrNull(); + } +} + +extension on RemoteExifEntityData { + ExifInfo toDto() { + return ExifInfo( + fileSize: fileSize, + description: description, + orientation: orientation, + timeZone: timeZone, + dateTimeOriginal: dateTimeOriginal, + latitude: latitude, + longitude: longitude, + city: city, + state: state, + country: country, + make: make, + model: model, + f: fNumber, + iso: iso, + ); + } +} diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 909c3b391..a9e081110 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,17 +1,13 @@ import 'package:drift/drift.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; -final remoteAssetRepositoryProvider = Provider( - (ref) => RemoteAssetRepository(ref.watch(driftProvider)), -); - -class RemoteAssetRepository extends DriftDatabaseRepository { +class DriftRemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; - const RemoteAssetRepository(this._db) : super(_db); + const DriftRemoteAssetRepository(this._db) : super(_db); Future updateFavorite(List ids, bool isFavorite) { return _db.batch((batch) async { @@ -36,4 +32,19 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } }); } + + Future updateLocation(List ids, LatLng location) { + return _db.batch((batch) async { + for (final id in ids) { + batch.update( + _db.remoteExifEntity, + RemoteExifEntityCompanion( + latitude: Value(location.latitude), + longitude: Value(location.longitude), + ), + where: (e) => e.assetId.equals(id), + ); + } + }); + } } diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart index e8a6b633c..c7279995f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart @@ -1,16 +1,54 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class EditLocationActionButton extends ConsumerWidget { - const EditLocationActionButton({super.key}); + final ActionSource source; + + const EditLocationActionButton({super.key, required this.source}); + + _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = + await ref.read(actionProvider.notifier).editLocation(source, context); + if (result == null) { + return; + } + + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'edit_location_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { return BaseActionButton( iconData: Icons.edit_location_alt_outlined, label: "control_bottom_app_bar_edit_location".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart index 4e55dbb3d..d122d188f 100644 --- a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart @@ -42,7 +42,7 @@ class HomeBottomAppBar extends ConsumerWidget { ? const TrashActionButton() : const DeletePermanentActionButton(), const EditDateTimeActionButton(), - const EditLocationActionButton(), + const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton( source: ActionSource.timeline, ), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 5d0db7b38..940adf10e 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -172,4 +172,26 @@ class ActionNotifier extends Notifier { ); } } + + Future editLocation( + ActionSource source, + BuildContext context, + ) async { + final ids = _getIdsForSource(source); + try { + final isEdited = await _service.editLocation(ids, context); + if (!isEdited) { + return null; + } + + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to edit location for assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } } diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 9faef5a53..28a6bda27 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -1,7 +1,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; final localAssetRepository = Provider( (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), ); + +final remoteAssetRepository = Provider( + (ref) => DriftRemoteAssetRepository(ref.watch(driftProvider)), +); diff --git a/mobile/lib/providers/infrastructure/exif.provider.dart b/mobile/lib/providers/infrastructure/exif.provider.dart index 59ad63292..af4bb933e 100644 --- a/mobile/lib/providers/infrastructure/exif.provider.dart +++ b/mobile/lib/providers/infrastructure/exif.provider.dart @@ -8,3 +8,7 @@ part 'exif.provider.g.dart'; @Riverpod(keepAlive: true) IsarExifRepository exifRepository(Ref ref) => IsarExifRepository(ref.watch(isarProvider)); + +final remoteExifRepository = Provider( + (ref) => DriftRemoteExifRepository(ref.watch(driftProvider)), +); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 923a21401..963142840 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( @@ -65,6 +66,19 @@ class AssetApiRepository extends ApiRepository { ); } + Future updateLocation( + List ids, + LatLng location, + ) async { + return _api.updateAssets( + AssetBulkUpdateDto( + ids: ids, + latitude: location.latitude, + longitude: location.longitude, + ), + ); + } + _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { AssetVisibilityEnum.timeline => AssetVisibility.timeline, AssetVisibilityEnum.hidden => AssetVisibility.hidden, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index df059405e..b5ddbac27 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -2,23 +2,34 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/location_picker.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; final actionServiceProvider = Provider( (ref) => ActionService( ref.watch(assetApiRepositoryProvider), - ref.watch(remoteAssetRepositoryProvider), + ref.watch(remoteAssetRepository), + ref.watch(remoteExifRepository), ), ); class ActionService { final AssetApiRepository _assetApiRepository; - final RemoteAssetRepository _remoteAssetRepository; + final DriftRemoteAssetRepository _remoteAssetRepository; + final DriftRemoteExifRepository _remoteExifRepository; - const ActionService(this._assetApiRepository, this._remoteAssetRepository); + const ActionService( + this._assetApiRepository, + this._remoteAssetRepository, + this._remoteExifRepository, + ); Future shareLink(List remoteIds, BuildContext context) async { context.pushRoute( @@ -81,4 +92,38 @@ class ActionService { AssetVisibility.timeline, ); } + + Future editLocation( + List remoteIds, + BuildContext context, + ) async { + LatLng? initialLatLng; + if (remoteIds.length == 1) { + final exif = await _remoteExifRepository.get(remoteIds[0]); + + if (exif?.latitude != null && exif?.longitude != null) { + initialLatLng = LatLng(exif!.latitude!, exif.longitude!); + } + } + + final location = await showLocationPicker( + context: context, + initialLatLng: initialLatLng, + ); + + if (location == null) { + return false; + } + + await _assetApiRepository.updateLocation( + remoteIds, + location, + ); + await _remoteAssetRepository.updateLocation( + remoteIds, + location, + ); + + return true; + } } diff --git a/mobile/openapi/lib/model/sync_asset_exif_v1.dart b/mobile/openapi/lib/model/sync_asset_exif_v1.dart index b0fef28b7621058524c2e496ce93fe2473af5309..d4fdc9249d6edfa6574e271a926df6ab09d2f3a2 100644 GIT binary patch delta 395 zcmcZ{vm$nb6f0XwerZxp>SS3~HERgdULh?%IWfm4H7~s+!;paPz>g0=9Snh zWESTmNlq?hRfQV0c`mD&tdT};VnJA9PHC!tT1aI&r8Rmaq}dVgPZ_paD$Nm delta 175 zcmZ1xdpTx<6f1LPUddzyRy89aZLg4)pPZQElbV-al3~rorJw*2EGR~BGK=$JoXL%> zsvzx~*RYz&PS#hJW%n!1O-e0NpDdyr2jWX2OjFm?0U8GZX$8fTb(Q6r3yRezKT~!E js+8f(Ni4}MDNRXLpB$$W3KHSU$(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}).toDouble(), + {{{name}}}: (mapValueOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}){{#isNullable}}?{{/isNullable}}.toDouble(), {{/isDouble}} {{^isDouble}} {{^isNumber}} diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache.patch b/open-api/templates/mobile/serialization/native/native_class.mustache.patch index 4ba659496..8eeefdad9 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache.patch +++ b/open-api/templates/mobile/serialization/native/native_class.mustache.patch @@ -1,5 +1,5 @@ ---- native_class.mustache 2024-09-19 11:41:07.855683995 -0400 -+++ native_class_temp.mustache 2024-09-19 11:41:57.113249395 -0400 +--- native_class.mustache 2025-07-01 08:29:23.968133163 +0800 ++++ native_class_temp.mustache 2025-07-01 08:29:44.225850583 +0800 @@ -91,14 +91,14 @@ {{/isDateTime}} {{#isNullable}} @@ -44,7 +44,7 @@ : {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'), {{/isNumber}} + {{#isDouble}} -+ {{{name}}}: (mapValueOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}).toDouble(), ++ {{{name}}}: (mapValueOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}){{#isNullable}}?{{/isNullable}}.toDouble(), + {{/isDouble}} + {{^isDouble}} {{^isNumber}} diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index c888f8944..db1fd2941 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -115,9 +115,9 @@ export class SyncAssetExifV1 { dateTimeOriginal!: Date | null; modifyDate!: Date | null; timeZone!: string | null; - @ApiProperty({ type: 'integer' }) + @ApiProperty({ type: 'number', format: 'double' }) latitude!: number | null; - @ApiProperty({ type: 'integer' }) + @ApiProperty({ type: 'number', format: 'double' }) longitude!: number | null; projectionType!: string | null; city!: string | null; @@ -126,9 +126,9 @@ export class SyncAssetExifV1 { make!: string | null; model!: string | null; lensModel!: string | null; - @ApiProperty({ type: 'integer' }) + @ApiProperty({ type: 'number', format: 'double' }) fNumber!: number | null; - @ApiProperty({ type: 'integer' }) + @ApiProperty({ type: 'number', format: 'double' }) focalLength!: number | null; @ApiProperty({ type: 'integer' }) iso!: number | null; @@ -136,7 +136,7 @@ export class SyncAssetExifV1 { profileDescription!: string | null; @ApiProperty({ type: 'integer' }) rating!: number | null; - @ApiProperty({ type: 'integer' }) + @ApiProperty({ type: 'number', format: 'double' }) fps!: number | null; }