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 3663d35b5..096128e52 100644 Binary files a/mobile/drift_schemas/main/drift_schema_v1.json and b/mobile/drift_schemas/main/drift_schema_v1.json differ 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 10025d9cb..a8fd3a447 100644 Binary files a/mobile/lib/infrastructure/entities/exif.entity.drift.dart and b/mobile/lib/infrastructure/entities/exif.entity.drift.dart differ 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 b0fef28b7..d4fdc9249 100644 Binary files a/mobile/openapi/lib/model/sync_asset_exif_v1.dart and b/mobile/openapi/lib/model/sync_asset_exif_v1.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ef66c985c..0dc0c43ec 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13615,36 +13615,41 @@ "type": "string" }, "fNumber": { + "format": "double", "nullable": true, - "type": "integer" + "type": "number" }, "fileSizeInByte": { "nullable": true, "type": "integer" }, "focalLength": { + "format": "double", "nullable": true, - "type": "integer" + "type": "number" }, "fps": { + "format": "double", "nullable": true, - "type": "integer" + "type": "number" }, "iso": { "nullable": true, "type": "integer" }, "latitude": { + "format": "double", "nullable": true, - "type": "integer" + "type": "number" }, "lensModel": { "nullable": true, "type": "string" }, "longitude": { + "format": "double", "nullable": true, - "type": "integer" + "type": "number" }, "make": { "nullable": true, diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache index 9f40d5b0b..2d6e6d24f 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache +++ b/open-api/templates/mobile/serialization/native/native_class.mustache @@ -206,7 +206,7 @@ class {{{classname}}} { : {{/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/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; }