浏览代码

feat(mobile): edit date time & location (#5461)

* chore: text correction

* fix: update activities stat only when the widget is mounted

* feat(mobile): edit date time

* feat(mobile): edit location

* chore(build): update gradle wrapper - 7.6.3

* style: dropdownmenu styling

* style: wrap locationpicker in singlechildscrollview

* test: add unit test for getTZAdjustedTimeAndOffset

* pr changes

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
shenlong 1 年之前
父节点
当前提交
086a957a2b

+ 2 - 2
mobile/android/gradle/wrapper/gradle-wrapper.properties

@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip
-distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
+distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80

+ 17 - 1
mobile/assets/i18n/en-US.json

@@ -144,6 +144,8 @@
   "control_bottom_app_bar_stack": "Stack",
   "control_bottom_app_bar_unarchive": "Unarchive",
   "control_bottom_app_bar_upload": "Upload",
+  "control_bottom_app_bar_edit_time": "Edit Date & Time",
+  "control_bottom_app_bar_edit_location": "Edit Location",
   "create_album_page_untitled": "Untitled",
   "create_shared_album_page_create": "Create",
   "create_shared_album_page_share": "Share",
@@ -165,6 +167,7 @@
   "exif_bottom_sheet_description": "Add Description...",
   "exif_bottom_sheet_details": "DETAILS",
   "exif_bottom_sheet_location": "LOCATION",
+  "exif_bottom_sheet_location_add": "Add a location",
   "experimental_settings_new_asset_list_subtitle": "Work in progress",
   "experimental_settings_new_asset_list_title": "Enable experimental photo grid",
   "experimental_settings_subtitle": "Use at your own risk!",
@@ -461,5 +464,18 @@
   "viewer_remove_from_stack": "Remove from Stack",
   "viewer_stack_use_as_main_asset": "Use as Main Asset",
   "viewer_unstack": "Un-Stack",
-  "scaffold_body_error_occured": "Error occured"
+  "scaffold_body_error_occurred": "Error occurred",
+  "edit_date_time_dialog_date_time": "Date and Time",
+  "edit_date_time_dialog_timezone": "Timezone",
+  "action_common_cancel": "Cancel",
+  "action_common_update": "Update",
+  "edit_location_dialog_title": "Location",
+  "map_location_picker_page_use_location": "Use this location",
+  "location_picker_choose_on_map": "Choose on map",
+  "location_picker_latitude": "Latitude",
+  "location_picker_latitude_hint": "Enter your latitude here",
+  "location_picker_latitude_error": "Enter a valid latitude",
+  "location_picker_longitude": "Longitude",
+  "location_picker_longitude_hint": "Enter your longitude here",
+  "location_picker_longitude_error": "Enter a valid longitude"
 }

+ 36 - 0
mobile/lib/extensions/asset_extensions.dart

@@ -0,0 +1,36 @@
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:timezone/timezone.dart';
+
+extension TZExtension on Asset {
+  /// Returns the created time of the asset from the exif info (if available) or from
+  /// the fileCreatedAt field, adjusted to the timezone value from the exif info along with
+  /// the timezone offset in [Duration]
+  (DateTime, Duration) getTZAdjustedTimeAndOffset() {
+    DateTime dt = fileCreatedAt.toLocal();
+    if (exifInfo?.dateTimeOriginal != null) {
+      dt = exifInfo!.dateTimeOriginal!;
+      if (exifInfo?.timeZone != null) {
+        dt = dt.toUtc();
+        try {
+          final location = getLocation(exifInfo!.timeZone!);
+          dt = TZDateTime.from(dt, location);
+        } on LocationNotFoundException {
+          RegExp re = RegExp(
+            r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
+            caseSensitive: false,
+          );
+          final m = re.firstMatch(exifInfo!.timeZone!);
+          if (m != null) {
+            final duration = Duration(
+              hours: int.parse(m.group(1) ?? '0'),
+              minutes: int.parse(m.group(2) ?? '0'),
+            );
+            dt = dt.add(duration);
+            return (dt, duration);
+          }
+        }
+      }
+    }
+    return (dt, dt.timeZoneOffset);
+  }
+}

+ 4 - 0
mobile/lib/extensions/duration_extensions.dart

@@ -0,0 +1,4 @@
+extension TZOffsetExtension on Duration {
+  String formatAsOffset() =>
+      "${isNegative ? '-' : '+'}${inHours.abs().toString().padLeft(2, '0')}:${inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
+}

+ 5 - 1
mobile/lib/modules/activities/providers/activity.provider.dart

@@ -95,7 +95,11 @@ class ActivityStatisticsNotifier extends StateNotifier<int> {
   }
 
   Future<void> fetchStatistics() async {
-    state = await _activityService.getStatistics(albumId, assetId: assetId);
+    final count =
+        await _activityService.getStatistics(albumId, assetId: assetId);
+    if (mounted) {
+      state = count;
+    }
   }
 
   Future<void> addActivity() async {

+ 126 - 104
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -4,14 +4,15 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asset_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
-import 'package:timezone/timezone.dart';
+import 'package:immich_mobile/extensions/duration_extensions.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
 import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.dart';
+import 'package:immich_mobile/utils/selection_handlers.dart';
 import 'package:latlong2/latlong.dart';
 import 'package:immich_mobile/utils/bytes_units.dart';
 import 'package:url_launcher/url_launcher.dart';
@@ -21,98 +22,69 @@ class ExifBottomSheet extends HookConsumerWidget {
 
   const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
 
-  bool hasCoordinates(ExifInfo? exifInfo) =>
-      exifInfo != null &&
-      exifInfo.latitude != null &&
-      exifInfo.longitude != null &&
-      exifInfo.latitude != 0 &&
-      exifInfo.longitude != 0;
-
-  String formatTimeZone(Duration d) =>
-      "GMT${d.isNegative ? '-' : '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
-
-  String get formattedDateTime {
-    DateTime dt = asset.fileCreatedAt.toLocal();
-    String? timeZone;
-    if (asset.exifInfo?.dateTimeOriginal != null) {
-      dt = asset.exifInfo!.dateTimeOriginal!;
-      if (asset.exifInfo?.timeZone != null) {
-        dt = dt.toUtc();
-        try {
-          final location = getLocation(asset.exifInfo!.timeZone!);
-          dt = TZDateTime.from(dt, location);
-        } on LocationNotFoundException {
-          RegExp re = RegExp(
-            r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
-            caseSensitive: false,
-          );
-          final m = re.firstMatch(asset.exifInfo!.timeZone!);
-          if (m != null) {
-            final duration = Duration(
-              hours: int.parse(m.group(1) ?? '0'),
-              minutes: int.parse(m.group(2) ?? '0'),
-            );
-            dt = dt.add(duration);
-            timeZone = formatTimeZone(duration);
-          }
-        }
-      }
-    }
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final assetWithExif = ref.watch(assetDetailProvider(asset));
+    final exifInfo = (assetWithExif.value ?? asset).exifInfo;
+    var textColor = context.isDarkTheme ? Colors.white : Colors.black;
 
-    final date = DateFormat.yMMMEd().format(dt);
-    final time = DateFormat.jm().format(dt);
-    timeZone ??= formatTimeZone(dt.timeZoneOffset);
+    bool hasCoordinates() =>
+        exifInfo != null &&
+        exifInfo.latitude != null &&
+        exifInfo.longitude != null &&
+        exifInfo.latitude != 0 &&
+        exifInfo.longitude != 0;
 
-    return '$date • $time $timeZone';
-  }
+    String formattedDateTime() {
+      final (dt, timeZone) =
+          (assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset();
+      final date = DateFormat.yMMMEd().format(dt);
+      final time = DateFormat.jm().format(dt);
 
-  Future<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {
-    if (!hasCoordinates(exifInfo)) {
-      return null;
+      return '$date • $time GMT${timeZone.formatAsOffset()}';
     }
 
-    final double latitude = exifInfo!.latitude!;
-    final double longitude = exifInfo.longitude!;
+    Future<Uri?> createCoordinatesUri() async {
+      if (!hasCoordinates()) {
+        return null;
+      }
+
+      final double latitude = exifInfo!.latitude!;
+      final double longitude = exifInfo.longitude!;
 
-    const zoomLevel = 16;
+      const zoomLevel = 16;
 
-    if (Platform.isAndroid) {
-      Uri uri = Uri(
-        scheme: 'geo',
-        host: '$latitude,$longitude',
-        queryParameters: {
+      if (Platform.isAndroid) {
+        Uri uri = Uri(
+          scheme: 'geo',
+          host: '$latitude,$longitude',
+          queryParameters: {
+            'z': '$zoomLevel',
+            'q': '$latitude,$longitude($formattedDateTime)',
+          },
+        );
+        if (await canLaunchUrl(uri)) {
+          return uri;
+        }
+      } else if (Platform.isIOS) {
+        var params = {
+          'll': '$latitude,$longitude',
+          'q': formattedDateTime,
           'z': '$zoomLevel',
-          'q': '$latitude,$longitude($formattedDateTime)',
-        },
-      );
-      if (await canLaunchUrl(uri)) {
-        return uri;
-      }
-    } else if (Platform.isIOS) {
-      var params = {
-        'll': '$latitude,$longitude',
-        'q': formattedDateTime,
-        'z': '$zoomLevel',
-      };
-      Uri uri = Uri.https('maps.apple.com', '/', params);
-      if (await canLaunchUrl(uri)) {
-        return uri;
+        };
+        Uri uri = Uri.https('maps.apple.com', '/', params);
+        if (await canLaunchUrl(uri)) {
+          return uri;
+        }
       }
-    }
-
-    return Uri(
-      scheme: 'https',
-      host: 'openstreetmap.org',
-      queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
-      fragment: 'map=$zoomLevel/$latitude/$longitude',
-    );
-  }
 
-  @override
-  Widget build(BuildContext context, WidgetRef ref) {
-    final assetWithExif = ref.watch(assetDetailProvider(asset));
-    final exifInfo = (assetWithExif.value ?? asset).exifInfo;
-    var textColor = context.isDarkTheme ? Colors.white : Colors.black;
+      return Uri(
+        scheme: 'https',
+        host: 'openstreetmap.org',
+        queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
+        fragment: 'map=$zoomLevel/$latitude/$longitude',
+      );
+    }
 
     buildMap() {
       return Padding(
@@ -120,12 +92,14 @@ class ExifBottomSheet extends HookConsumerWidget {
         child: LayoutBuilder(
           builder: (context, constraints) {
             return MapThumbnail(
+              showAttribution: false,
               coords: LatLng(
                 exifInfo?.latitude ?? 0,
                 exifInfo?.longitude ?? 0,
               ),
               height: 150,
-              zoom: 16.0,
+              width: constraints.maxWidth,
+              zoom: 12.0,
               markers: [
                 Marker(
                   anchorPos: AnchorPos.align(AnchorAlign.top),
@@ -139,7 +113,7 @@ class ExifBottomSheet extends HookConsumerWidget {
                 ),
               ],
               onTap: (tapPosition, latLong) async {
-                Uri? uri = await _createCoordinatesUri(exifInfo);
+                Uri? uri = await createCoordinatesUri();
 
                 if (uri == null) {
                   return;
@@ -181,8 +155,26 @@ class ExifBottomSheet extends HookConsumerWidget {
 
     buildLocation() {
       // Guard no lat/lng
-      if (!hasCoordinates(exifInfo)) {
-        return Container();
+      if (!hasCoordinates()) {
+        return asset.isRemote
+            ? ListTile(
+                minLeadingWidth: 0,
+                contentPadding: const EdgeInsets.all(0),
+                leading: const Icon(Icons.location_on),
+                title: Text(
+                  "exif_bottom_sheet_location_add",
+                  style: context.textTheme.bodyMedium?.copyWith(
+                    fontWeight: FontWeight.w600,
+                    color: context.primaryColor,
+                  ),
+                ).tr(),
+                onTap: () => handleEditLocation(
+                  ref,
+                  context,
+                  [assetWithExif.value ?? asset],
+                ),
+              )
+            : const SizedBox.shrink();
       }
 
       return Column(
@@ -191,13 +183,29 @@ class ExifBottomSheet extends HookConsumerWidget {
           Column(
             crossAxisAlignment: CrossAxisAlignment.start,
             children: [
-              Text(
-                "exif_bottom_sheet_location",
-                style: context.textTheme.labelMedium?.copyWith(
-                  color: context.textTheme.labelMedium?.color?.withAlpha(200),
-                  fontWeight: FontWeight.w600,
-                ),
-              ).tr(),
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Text(
+                    "exif_bottom_sheet_location",
+                    style: context.textTheme.labelMedium?.copyWith(
+                      color:
+                          context.textTheme.labelMedium?.color?.withAlpha(200),
+                      fontWeight: FontWeight.w600,
+                    ),
+                  ).tr(),
+                  if (asset.isRemote)
+                    IconButton(
+                      onPressed: () => handleEditLocation(
+                        ref,
+                        context,
+                        [assetWithExif.value ?? asset],
+                      ),
+                      icon: const Icon(Icons.edit_outlined),
+                      iconSize: 20,
+                    ),
+                ],
+              ),
               buildMap(),
               RichText(
                 text: TextSpan(
@@ -233,12 +241,27 @@ class ExifBottomSheet extends HookConsumerWidget {
     }
 
     buildDate() {
-      return Text(
-        formattedDateTime,
-        style: const TextStyle(
-          fontWeight: FontWeight.bold,
-          fontSize: 14,
-        ),
+      return Row(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          Text(
+            formattedDateTime(),
+            style: const TextStyle(
+              fontWeight: FontWeight.bold,
+              fontSize: 14,
+            ),
+          ),
+          if (asset.isRemote)
+            IconButton(
+              onPressed: () => handleEditDateTime(
+                ref,
+                context,
+                [assetWithExif.value ?? asset],
+              ),
+              icon: const Icon(Icons.edit_outlined),
+              iconSize: 20,
+            ),
+        ],
       );
     }
 
@@ -363,7 +386,7 @@ class ExifBottomSheet extends HookConsumerWidget {
                           crossAxisAlignment: CrossAxisAlignment.start,
                           children: [
                             Flexible(
-                              flex: hasCoordinates(exifInfo) ? 5 : 0,
+                              flex: hasCoordinates() ? 5 : 0,
                               child: Padding(
                                 padding: const EdgeInsets.only(right: 8.0),
                                 child: buildLocation(),
@@ -402,9 +425,8 @@ class ExifBottomSheet extends HookConsumerWidget {
                         child: CircularProgressIndicator.adaptive(),
                       ),
                     ),
-                    const SizedBox(height: 8.0),
                     buildLocation(),
-                    SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),
+                    SizedBox(height: hasCoordinates() ? 16.0 : 6.0),
                     buildDetail(),
                     const SizedBox(height: 50),
                   ],

+ 16 - 0
mobile/lib/modules/home/ui/control_bottom_app_bar.dart

@@ -19,6 +19,8 @@ class ControlBottomAppBar extends ConsumerWidget {
   final void Function() onCreateNewAlbum;
   final void Function() onUpload;
   final void Function() onStack;
+  final void Function() onEditTime;
+  final void Function() onEditLocation;
 
   final List<Album> albums;
   final List<Album> sharedAlbums;
@@ -37,6 +39,8 @@ class ControlBottomAppBar extends ConsumerWidget {
     required this.onCreateNewAlbum,
     required this.onUpload,
     required this.onStack,
+    required this.onEditTime,
+    required this.onEditLocation,
     this.selectionAssetState = const SelectionAssetState(),
     this.enabled = true,
   }) : super(key: key);
@@ -74,6 +78,18 @@ class ControlBottomAppBar extends ConsumerWidget {
             label: "control_bottom_app_bar_favorite".tr(),
             onPressed: enabled ? onFavorite : null,
           ),
+        if (hasRemote)
+          ControlBoxButton(
+            iconData: Icons.edit_calendar_outlined,
+            label: "control_bottom_app_bar_edit_time".tr(),
+            onPressed: enabled ? onEditTime : null,
+          ),
+        if (hasRemote)
+          ControlBoxButton(
+            iconData: Icons.edit_location_alt_outlined,
+            label: "control_bottom_app_bar_edit_location".tr(),
+            onPressed: enabled ? onEditLocation : null,
+          ),
         ControlBoxButton(
           iconData: Icons.delete_outline_rounded,
           label: "control_bottom_app_bar_delete".tr(),

+ 34 - 4
mobile/lib/modules/home/views/home_page.dart

@@ -213,10 +213,10 @@ class HomePage extends HookConsumerWidget {
         processing.value = true;
         selectionEnabledHook.value = false;
         try {
-            ref.read(manualUploadProvider.notifier).uploadAssets(
-                  context,
-                  selection.value.where((a) => a.storage == AssetState.local),
-                );
+          ref.read(manualUploadProvider.notifier).uploadAssets(
+                context,
+                selection.value.where((a) => a.storage == AssetState.local),
+              );
         } finally {
           processing.value = false;
         }
@@ -312,6 +312,34 @@ class HomePage extends HookConsumerWidget {
         }
       }
 
+      void onEditTime() async {
+        try {
+          final remoteAssets = ownedRemoteSelection(
+            localErrorMessage: 'home_page_favorite_err_local'.tr(),
+            ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
+          );
+          if (remoteAssets.isNotEmpty) {
+            handleEditDateTime(ref, context, remoteAssets.toList());
+          }
+        } finally {
+          selectionEnabledHook.value = false;
+        }
+      }
+
+      void onEditLocation() async {
+        try {
+          final remoteAssets = ownedRemoteSelection(
+            localErrorMessage: 'home_page_favorite_err_local'.tr(),
+            ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
+          );
+          if (remoteAssets.isNotEmpty) {
+            handleEditLocation(ref, context, remoteAssets.toList());
+          }
+        } finally {
+          selectionEnabledHook.value = false;
+        }
+      }
+
       Future<void> refreshAssets() async {
         final fullRefresh = refreshCount.value > 0;
         await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
@@ -411,6 +439,8 @@ class HomePage extends HookConsumerWidget {
                 enabled: !processing.value,
                 selectionAssetState: selectionAssetState.value,
                 onStack: onStack,
+                onEditTime: onEditTime,
+                onEditLocation: onEditLocation,
               ),
           ],
         ),

+ 113 - 0
mobile/lib/modules/map/ui/map_location_picker.dart

@@ -0,0 +1,113 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_map/flutter_map.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/utils/immich_app_theme.dart';
+import 'package:latlong2/latlong.dart';
+
+class MapLocationPickerPage extends HookConsumerWidget {
+  final LatLng? initialLatLng;
+
+  const MapLocationPickerPage({super.key, this.initialLatLng});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final selectedLatLng = useState<LatLng>(initialLatLng ?? LatLng(0, 0));
+    final isDarkTheme =
+        ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
+    final isLoading =
+        ref.watch(mapStateNotifier.select((state) => state.isLoading));
+    final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
+
+    return Theme(
+      // Override app theme based on map theme
+      data: isDarkTheme ? immichDarkTheme : immichLightTheme,
+      child: Scaffold(
+        extendBodyBehindAppBar: true,
+        body: Stack(
+          children: [
+            if (!isLoading)
+              FlutterMap(
+                options: MapOptions(
+                  maxBounds:
+                      LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
+                  interactiveFlags: InteractiveFlag.doubleTapZoom |
+                      InteractiveFlag.drag |
+                      InteractiveFlag.flingAnimation |
+                      InteractiveFlag.pinchMove |
+                      InteractiveFlag.pinchZoom,
+                  center: LatLng(20, 20),
+                  zoom: 2,
+                  minZoom: 1,
+                  maxZoom: maxZoom,
+                  onTap: (tapPosition, point) => selectedLatLng.value = point,
+                ),
+                children: [
+                  ref.read(mapStateNotifier.notifier).getTileLayer(),
+                  MarkerLayer(
+                    markers: [
+                      Marker(
+                        anchorPos: AnchorPos.align(AnchorAlign.top),
+                        point: selectedLatLng.value,
+                        builder: (ctx) => const Image(
+                          image: AssetImage('assets/location-pin.png'),
+                        ),
+                        height: 40,
+                        width: 40,
+                      ),
+                    ],
+                  ),
+                ],
+              ),
+            if (isLoading)
+              Positioned(
+                top: context.height * 0.35,
+                left: context.width * 0.425,
+                child: const ImmichLoadingIndicator(),
+              ),
+          ],
+        ),
+        bottomSheet: BottomSheet(
+          onClosing: () {},
+          builder: (context) => SizedBox(
+            height: 150,
+            child: Column(
+              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+              crossAxisAlignment: CrossAxisAlignment.center,
+              children: [
+                Text(
+                  "${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}",
+                  style: context.textTheme.bodyLarge?.copyWith(
+                    color: context.primaryColor,
+                    fontWeight: FontWeight.w600,
+                  ),
+                ),
+                Row(
+                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+                  children: [
+                    ElevatedButton(
+                      onPressed: () => context.autoPop(selectedLatLng.value),
+                      child: const Text("map_location_picker_page_use_location")
+                          .tr(),
+                    ),
+                    ElevatedButton(
+                      onPressed: () => context.autoPop(),
+                      style: ElevatedButton.styleFrom(
+                        backgroundColor: context.colorScheme.error,
+                      ),
+                      child: const Text("action_common_cancel").tr(),
+                    ),
+                  ],
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 20 - 1
mobile/lib/modules/map/ui/map_thumbnail.dart

@@ -1,7 +1,9 @@
 import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:flutter_map/plugin_api.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
+import 'package:immich_mobile/modules/map/utils/map_controller_hook.dart';
 import 'package:latlong2/latlong.dart';
 import 'package:url_launcher/url_launcher.dart';
 
@@ -12,13 +14,15 @@ class MapThumbnail extends HookConsumerWidget {
   final double zoom;
   final List<Marker> markers;
   final double height;
+  final double width;
   final bool showAttribution;
   final bool isDarkTheme;
 
   const MapThumbnail({
     super.key,
     required this.coords,
-    required this.height,
+    this.height = 100,
+    this.width = 100,
     this.onTap,
     this.zoom = 1,
     this.showAttribution = true,
@@ -28,18 +32,33 @@ class MapThumbnail extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final mapController = useMapController();
+    final isMapReady = useRef(false);
     ref.watch(mapStateNotifier.select((s) => s.mapStyle));
 
+    useEffect(
+      () {
+        if (isMapReady.value && mapController.center != coords) {
+          mapController.move(coords, zoom);
+        }
+        return null;
+      },
+      [coords],
+    );
+
     return SizedBox(
       height: height,
+      width: width,
       child: ClipRRect(
         borderRadius: const BorderRadius.all(Radius.circular(15)),
         child: FlutterMap(
+          mapController: mapController,
           options: MapOptions(
             interactiveFlags: InteractiveFlag.none,
             center: coords,
             zoom: zoom,
             onTap: onTap,
+            onMapReady: () => isMapReady.value = true,
           ),
           nonRotatedChildren: [
             if (showAttribution)

+ 32 - 0
mobile/lib/modules/map/utils/map_controller_hook.dart

@@ -0,0 +1,32 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_map/flutter_map.dart';
+
+MapController useMapController({
+  String? debugLabel,
+  List<Object?>? keys,
+}) {
+  return use(_MapControllerHook(keys: keys));
+}
+
+class _MapControllerHook extends Hook<MapController> {
+  const _MapControllerHook({List<Object?>? keys}) : super(keys: keys);
+
+  @override
+  HookState<MapController, Hook<MapController>> createState() =>
+      _MapControllerHookState();
+}
+
+class _MapControllerHookState
+    extends HookState<MapController, _MapControllerHook> {
+  late final controller = MapController();
+
+  @override
+  MapController build(BuildContext context) => controller;
+
+  @override
+  void dispose() => controller.dispose();
+
+  @override
+  String get debugLabel => 'useMapController';
+}

+ 3 - 1
mobile/lib/modules/map/views/map_page.dart

@@ -55,6 +55,7 @@ class MapPageState extends ConsumerState<MapPage> {
   // in onMapEvent() since MapEventMove#id is not populated properly in the
   // current version of flutter_map(4.0.0) used
   bool forceAssetUpdate = false;
+  bool isMapReady = false;
   late final Debounce debounce;
 
   @override
@@ -79,7 +80,7 @@ class MapPageState extends ConsumerState<MapPage> {
     bool forceReload = false,
   }) {
     try {
-      final bounds = mapController.bounds;
+      final bounds = isMapReady ? mapController.bounds : null;
       if (bounds != null) {
         final oldAssetsInBounds = assetsInBounds.toSet();
         assetsInBounds =
@@ -455,6 +456,7 @@ class MapPageState extends ConsumerState<MapPage> {
                     minZoom: 1,
                     maxZoom: maxZoom,
                     onMapReady: () {
+                      isMapReady = true;
                       mapController.mapEventStream.listen(onMapEvent);
                     },
                   ),

+ 3 - 3
mobile/lib/modules/search/ui/curated_places_row.dart

@@ -29,9 +29,8 @@ class CuratedPlacesRow extends CuratedRow {
         onTap: () => context.autoPush(
           const MapRoute(),
         ),
-        child: SizedBox(
-          height: imageSize,
-          width: imageSize,
+        child: SizedBox.square(
+          dimension: imageSize,
           child: Stack(
             children: [
               Padding(
@@ -43,6 +42,7 @@ class CuratedPlacesRow extends CuratedRow {
                     5,
                   ),
                   height: imageSize,
+                  width: imageSize,
                   showAttribution: false,
                   isDarkTheme: context.isDarkTheme,
                 ),

+ 7 - 1
mobile/lib/routing/router.dart

@@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
 import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
 import 'package:immich_mobile/modules/album/views/create_album_page.dart';
 import 'package:immich_mobile/modules/album/views/library_page.dart';
+import 'package:immich_mobile/modules/map/ui/map_location_picker.dart';
 import 'package:immich_mobile/modules/map/views/map_page.dart';
 import 'package:immich_mobile/modules/memories/models/memory.dart';
 import 'package:immich_mobile/modules/memories/views/memory_page.dart';
@@ -57,7 +58,8 @@ import 'package:immich_mobile/shared/views/app_log_page.dart';
 import 'package:immich_mobile/shared/views/splash_screen.dart';
 import 'package:immich_mobile/shared/views/tab_controller_page.dart';
 import 'package:isar/isar.dart';
-import 'package:photo_manager/photo_manager.dart';
+import 'package:photo_manager/photo_manager.dart' hide LatLng;
+import 'package:latlong2/latlong.dart';
 
 part 'router.gr.dart';
 
@@ -172,6 +174,10 @@ part 'router.gr.dart';
       transitionsBuilder: TransitionsBuilders.slideLeft,
       durationInMilliseconds: 200,
     ),
+    CustomRoute<LatLng?>(
+      page: MapLocationPickerPage,
+      guards: [AuthGuard, DuplicateGuard],
+    ),
   ],
 )
 class AppRouter extends _$AppRouter {

+ 55 - 0
mobile/lib/routing/router.gr.dart

@@ -360,6 +360,19 @@ class _$AppRouter extends RootStackRouter {
         barrierDismissible: false,
       );
     },
+    MapLocationPickerRoute.name: (routeData) {
+      final args = routeData.argsAs<MapLocationPickerRouteArgs>(
+          orElse: () => const MapLocationPickerRouteArgs());
+      return CustomPage<LatLng?>(
+        routeData: routeData,
+        child: MapLocationPickerPage(
+          key: args.key,
+          initialLatLng: args.initialLatLng,
+        ),
+        opaque: true,
+        barrierDismissible: false,
+      );
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
         routeData: routeData,
@@ -704,6 +717,14 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
           ],
         ),
+        RouteConfig(
+          MapLocationPickerRoute.name,
+          path: '/map-location-picker-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
       ];
 }
 
@@ -1621,6 +1642,40 @@ class ActivitiesRouteArgs {
   }
 }
 
+/// generated route for
+/// [MapLocationPickerPage]
+class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
+  MapLocationPickerRoute({
+    Key? key,
+    LatLng? initialLatLng,
+  }) : super(
+          MapLocationPickerRoute.name,
+          path: '/map-location-picker-page',
+          args: MapLocationPickerRouteArgs(
+            key: key,
+            initialLatLng: initialLatLng,
+          ),
+        );
+
+  static const String name = 'MapLocationPickerRoute';
+}
+
+class MapLocationPickerRouteArgs {
+  const MapLocationPickerRouteArgs({
+    this.key,
+    this.initialLatLng,
+  });
+
+  final Key? key;
+
+  final LatLng? initialLatLng;
+
+  @override
+  String toString() {
+    return 'MapLocationPickerRouteArgs{key: $key, initialLatLng: $initialLatLng}';
+  }
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 2 - 0
mobile/lib/shared/models/asset.dart

@@ -256,6 +256,8 @@ class Asset {
         isFavorite != a.isFavorite ||
         isArchived != a.isArchived ||
         isTrashed != a.isTrashed ||
+        a.exifInfo?.latitude != exifInfo?.latitude ||
+        a.exifInfo?.longitude != exifInfo?.longitude ||
         // no local stack count or different count from remote
         ((stackCount == null && a.stackCount != null) ||
             (stackCount != null &&

+ 24 - 0
mobile/lib/shared/services/asset.service.dart

@@ -11,6 +11,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/shared/services/sync.service.dart';
 import 'package:isar/isar.dart';
+import 'package:latlong2/latlong.dart';
 import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 
@@ -181,4 +182,27 @@ class AssetService {
   Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) {
     return updateAssets(assets, UpdateAssetDto(isArchived: isArchive));
   }
+
+  Future<List<Asset?>> changeDateTime(
+    List<Asset> assets,
+    String updatedDt,
+  ) {
+    return updateAssets(
+      assets,
+      UpdateAssetDto(dateTimeOriginal: updatedDt),
+    );
+  }
+
+  Future<List<Asset?>> changeLocation(
+    List<Asset> assets,
+    LatLng location,
+  ) {
+    return updateAssets(
+      assets,
+      UpdateAssetDto(
+        latitude: location.latitude,
+        longitude: location.longitude,
+      ),
+    );
+  }
 }

+ 257 - 0
mobile/lib/shared/ui/date_time_picker.dart

@@ -0,0 +1,257 @@
+import 'package:collection/collection.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/extensions/duration_extensions.dart';
+import 'package:timezone/timezone.dart' as tz;
+import 'package:timezone/timezone.dart';
+
+Future<String?> showDateTimePicker({
+  required BuildContext context,
+  DateTime? initialDateTime,
+  String? initialTZ,
+  Duration? initialTZOffset,
+}) {
+  return showDialog<String?>(
+    context: context,
+    builder: (context) => _DateTimePicker(
+      initialDateTime: initialDateTime,
+      initialTZ: initialTZ,
+      initialTZOffset: initialTZOffset,
+    ),
+  );
+}
+
+String _getFormattedOffset(int offsetInMilli, tz.Location location) {
+  return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
+}
+
+class _DateTimePicker extends HookWidget {
+  final DateTime? initialDateTime;
+  final String? initialTZ;
+  final Duration? initialTZOffset;
+
+  const _DateTimePicker({
+    this.initialDateTime,
+    this.initialTZ,
+    this.initialTZOffset,
+  });
+
+  _TimeZoneOffset _getInitiationLocation() {
+    if (initialTZ != null) {
+      try {
+        return _TimeZoneOffset.fromLocation(
+          tz.timeZoneDatabase.get(initialTZ!),
+        );
+      } on LocationNotFoundException {
+        // no-op
+      }
+    }
+
+    Duration? tzOffset = initialTZOffset ?? initialDateTime?.timeZoneOffset;
+
+    if (tzOffset != null) {
+      final offsetInMilli = tzOffset.inMilliseconds;
+      // get all locations with matching offset
+      final locations = tz.timeZoneDatabase.locations.values.where(
+        (location) => location.currentTimeZone.offset == offsetInMilli,
+      );
+      // Prefer locations with abbreviation first
+      final location = locations.firstWhereOrNull(
+            (e) => !e.currentTimeZone.abbreviation.contains("0"),
+          ) ??
+          locations.firstOrNull;
+      if (location != null) {
+        return _TimeZoneOffset.fromLocation(location);
+      }
+    }
+
+    return _TimeZoneOffset.fromLocation(tz.getLocation("UTC"));
+  }
+
+  // returns a list of location<name> along with it's offset in duration
+  List<_TimeZoneOffset> getAllTimeZones() {
+    return tz.timeZoneDatabase.locations.values
+        .where((l) => !l.currentTimeZone.abbreviation.contains("0"))
+        .map(_TimeZoneOffset.fromLocation)
+        .sorted()
+        .toList();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final date = useState<DateTime>(initialDateTime ?? DateTime.now());
+    final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation());
+    final timeZones = useMemoized(() => getAllTimeZones(), const []);
+
+    void pickDate() async {
+      final newDate = await showDatePicker(
+        context: context,
+        initialDate: date.value,
+        firstDate: DateTime(1800),
+        lastDate: DateTime.now(),
+      );
+      if (newDate == null) {
+        return;
+      }
+
+      final newTime = await showTimePicker(
+        context: context,
+        initialTime: TimeOfDay.fromDateTime(date.value),
+      );
+
+      if (newTime == null) {
+        return;
+      }
+
+      date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute);
+    }
+
+    void popWithDateTime() {
+      final formattedDateTime =
+          DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value);
+      final dtWithOffset = formattedDateTime +
+          Duration(milliseconds: tzOffset.value.offsetInMilliseconds)
+              .formatAsOffset();
+      context.pop(dtWithOffset);
+    }
+
+    return AlertDialog(
+      contentPadding: const EdgeInsets.all(30),
+      alignment: Alignment.center,
+      content: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          const Text(
+            "edit_date_time_dialog_date_time",
+            textAlign: TextAlign.center,
+          ).tr(),
+          TextButton.icon(
+            onPressed: pickDate,
+            icon: Text(
+              DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
+              style: context.textTheme.bodyLarge
+                  ?.copyWith(color: context.primaryColor),
+            ),
+            label: const Icon(
+              Icons.edit_outlined,
+              size: 18,
+            ),
+          ),
+          const Text(
+            "edit_date_time_dialog_timezone",
+            textAlign: TextAlign.center,
+          ).tr(),
+          DropdownMenu(
+            menuHeight: 300,
+            width: 280,
+            inputDecorationTheme: const InputDecorationTheme(
+              border: InputBorder.none,
+              contentPadding: EdgeInsets.zero,
+            ),
+            trailingIcon: Padding(
+              padding: const EdgeInsets.only(right: 10),
+              child: Icon(
+                Icons.arrow_drop_down,
+                color: context.primaryColor,
+              ),
+            ),
+            textStyle: context.textTheme.bodyLarge?.copyWith(
+              color: context.primaryColor,
+            ),
+            menuStyle: const MenuStyle(
+              fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)),
+              alignment: Alignment(-1.25, 0.5),
+            ),
+            onSelected: (value) => tzOffset.value = value!,
+            initialSelection: tzOffset.value,
+            dropdownMenuEntries: timeZones
+                .map(
+                  (t) => DropdownMenuEntry<_TimeZoneOffset>(
+                    value: t,
+                    label: t.display,
+                    style: ButtonStyle(
+                      textStyle: MaterialStatePropertyAll(
+                        context.textTheme.bodyMedium,
+                      ),
+                    ),
+                  ),
+                )
+                .toList(),
+          ),
+        ],
+      ),
+      actions: [
+        TextButton(
+          onPressed: () => context.pop(),
+          child: Text(
+            "action_common_cancel",
+            style: context.textTheme.bodyMedium?.copyWith(
+              fontWeight: FontWeight.w600,
+              color: context.colorScheme.error,
+            ),
+          ).tr(),
+        ),
+        TextButton(
+          onPressed: popWithDateTime,
+          child: Text(
+            "action_common_update",
+            style: context.textTheme.bodyMedium?.copyWith(
+              fontWeight: FontWeight.w600,
+              color: context.primaryColor,
+            ),
+          ).tr(),
+        ),
+      ],
+    );
+  }
+}
+
+class _TimeZoneOffset implements Comparable<_TimeZoneOffset> {
+  final String display;
+  final Location location;
+
+  const _TimeZoneOffset({
+    required this.display,
+    required this.location,
+  });
+
+  _TimeZoneOffset copyWith({
+    String? display,
+    Location? location,
+  }) {
+    return _TimeZoneOffset(
+      display: display ?? this.display,
+      location: location ?? this.location,
+    );
+  }
+
+  int get offsetInMilliseconds => location.currentTimeZone.offset;
+
+  _TimeZoneOffset.fromLocation(tz.Location l)
+      : display = _getFormattedOffset(l.currentTimeZone.offset, l),
+        location = l;
+
+  @override
+  int compareTo(_TimeZoneOffset other) {
+    return offsetInMilliseconds.compareTo(other.offsetInMilliseconds);
+  }
+
+  @override
+  String toString() =>
+      '_TimeZoneOffset(display: $display, location: $location)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is _TimeZoneOffset &&
+        other.display == display &&
+        other.offsetInMilliseconds == offsetInMilliseconds;
+  }
+
+  @override
+  int get hashCode =>
+      display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode;
+}

+ 256 - 0
mobile/lib/shared/ui/location_picker.dart

@@ -0,0 +1,256 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_map/plugin_api.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/extensions/string_extensions.dart';
+import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:latlong2/latlong.dart';
+
+Future<LatLng?> showLocationPicker({
+  required BuildContext context,
+  LatLng? initialLatLng,
+}) {
+  return showDialog<LatLng?>(
+    context: context,
+    useRootNavigator: false,
+    builder: (ctx) => _LocationPicker(
+      initialLatLng: initialLatLng,
+    ),
+  );
+}
+
+enum _LocationPickerMode { map, manual }
+
+bool _validateLat(String value) {
+  final l = double.tryParse(value);
+  return l != null && l > -90 && l < 90;
+}
+
+bool _validateLong(String value) {
+  final l = double.tryParse(value);
+  return l != null && l > -180 && l < 180;
+}
+
+class _LocationPicker extends HookWidget {
+  final LatLng? initialLatLng;
+
+  const _LocationPicker({
+    this.initialLatLng,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final latitude = useState(initialLatLng?.latitude ?? 0.0);
+    final longitude = useState(initialLatLng?.longitude ?? 0.0);
+    final latlng = LatLng(latitude.value, longitude.value);
+    final pickerMode = useState(_LocationPickerMode.map);
+    final latitudeController = useTextEditingController();
+    final isValidLatitude = useState(true);
+    final latitiudeFocusNode = useFocusNode();
+    final longitudeController = useTextEditingController();
+    final longitudeFocusNode = useFocusNode();
+    final isValidLongitude = useState(true);
+
+    void validateInputs() {
+      isValidLatitude.value = _validateLat(latitudeController.text);
+      if (isValidLatitude.value) {
+        latitude.value = latitudeController.text.toDouble();
+      }
+      isValidLongitude.value = _validateLong(longitudeController.text);
+      if (isValidLongitude.value) {
+        longitude.value = longitudeController.text.toDouble();
+      }
+    }
+
+    void validateAndPop() {
+      if (pickerMode.value == _LocationPickerMode.manual) {
+        validateInputs();
+      }
+      if (isValidLatitude.value && isValidLongitude.value) {
+        return context.pop(latlng);
+      }
+    }
+
+    List<Widget> buildMapPickerMode() {
+      return [
+        TextButton.icon(
+          icon: Text(
+            "${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}",
+          ),
+          label: const Icon(Icons.edit_outlined, size: 16),
+          onPressed: () {
+            latitudeController.text = latitude.value.toStringAsFixed(4);
+            longitudeController.text = longitude.value.toStringAsFixed(4);
+            pickerMode.value = _LocationPickerMode.manual;
+          },
+        ),
+        const SizedBox(
+          height: 12,
+        ),
+        MapThumbnail(
+          coords: latlng,
+          height: 200,
+          width: 200,
+          zoom: 6,
+          showAttribution: false,
+          onTap: (p0, p1) async {
+            final newLatLng = await context.autoPush<LatLng?>(
+              MapLocationPickerRoute(initialLatLng: latlng),
+            );
+            if (newLatLng != null) {
+              latitude.value = newLatLng.latitude;
+              longitude.value = newLatLng.longitude;
+            }
+          },
+          markers: [
+            Marker(
+              anchorPos: AnchorPos.align(AnchorAlign.top),
+              point: LatLng(
+                latitude.value,
+                longitude.value,
+              ),
+              builder: (ctx) => const Image(
+                image: AssetImage('assets/location-pin.png'),
+              ),
+            ),
+          ],
+        ),
+      ];
+    }
+
+    List<Widget> buildManualPickerMode() {
+      return [
+        TextButton.icon(
+          icon: const Text("location_picker_choose_on_map").tr(),
+          label: const Icon(Icons.map_outlined, size: 16),
+          onPressed: () {
+            validateInputs();
+            if (isValidLatitude.value && isValidLongitude.value) {
+              pickerMode.value = _LocationPickerMode.map;
+            }
+          },
+        ),
+        const SizedBox(
+          height: 12,
+        ),
+        TextField(
+          controller: latitudeController,
+          focusNode: latitiudeFocusNode,
+          textInputAction: TextInputAction.done,
+          autofocus: false,
+          decoration: InputDecoration(
+            labelText: 'location_picker_latitude'.tr(),
+            labelStyle: TextStyle(
+              fontWeight: FontWeight.bold,
+              color: context.primaryColor,
+            ),
+            floatingLabelBehavior: FloatingLabelBehavior.auto,
+            border: const OutlineInputBorder(),
+            hintText: 'location_picker_latitude_hint'.tr(),
+            hintStyle: const TextStyle(
+              fontWeight: FontWeight.normal,
+              fontSize: 14,
+            ),
+            errorText: isValidLatitude.value
+                ? null
+                : "location_picker_latitude_error".tr(),
+          ),
+          onEditingComplete: () {
+            isValidLatitude.value = _validateLat(latitudeController.text);
+            if (isValidLatitude.value) {
+              latitude.value = latitudeController.text.toDouble();
+              longitudeFocusNode.requestFocus();
+            }
+          },
+          keyboardType: const TextInputType.numberWithOptions(decimal: true),
+          inputFormatters: [LengthLimitingTextInputFormatter(8)],
+          onTapOutside: (_) => latitiudeFocusNode.unfocus(),
+        ),
+        const SizedBox(
+          height: 24,
+        ),
+        TextField(
+          controller: longitudeController,
+          focusNode: longitudeFocusNode,
+          textInputAction: TextInputAction.done,
+          autofocus: false,
+          decoration: InputDecoration(
+            labelText: 'location_picker_longitude'.tr(),
+            labelStyle: TextStyle(
+              fontWeight: FontWeight.bold,
+              color: context.primaryColor,
+            ),
+            floatingLabelBehavior: FloatingLabelBehavior.auto,
+            border: const OutlineInputBorder(),
+            hintText: 'location_picker_longitude_hint'.tr(),
+            hintStyle: const TextStyle(
+              fontWeight: FontWeight.normal,
+              fontSize: 14,
+            ),
+            errorText: isValidLongitude.value
+                ? null
+                : "location_picker_longitude_error".tr(),
+          ),
+          onEditingComplete: () {
+            isValidLongitude.value = _validateLong(longitudeController.text);
+            if (isValidLongitude.value) {
+              longitude.value = longitudeController.text.toDouble();
+              longitudeFocusNode.unfocus();
+            }
+          },
+          keyboardType: const TextInputType.numberWithOptions(decimal: true),
+          inputFormatters: [LengthLimitingTextInputFormatter(8)],
+          onTapOutside: (_) => longitudeFocusNode.unfocus(),
+        ),
+      ];
+    }
+
+    return AlertDialog(
+      contentPadding: const EdgeInsets.all(30),
+      alignment: Alignment.center,
+      content: SingleChildScrollView(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            const Text(
+              "edit_location_dialog_title",
+              textAlign: TextAlign.center,
+            ).tr(),
+            const SizedBox(
+              height: 12,
+            ),
+            if (pickerMode.value == _LocationPickerMode.manual)
+              ...buildManualPickerMode(),
+            if (pickerMode.value == _LocationPickerMode.map)
+              ...buildMapPickerMode(),
+          ],
+        ),
+      ),
+      actions: [
+        TextButton(
+          onPressed: () => context.pop(),
+          child: Text(
+            "action_common_cancel",
+            style: context.textTheme.bodyMedium?.copyWith(
+              fontWeight: FontWeight.w600,
+              color: context.colorScheme.error,
+            ),
+          ).tr(),
+        ),
+        TextButton(
+          onPressed: validateAndPop,
+          child: Text(
+            "action_common_update",
+            style: context.textTheme.bodyMedium?.copyWith(
+              fontWeight: FontWeight.w600,
+              color: context.primaryColor,
+            ),
+          ).tr(),
+        ),
+      ],
+    );
+  }
+}

+ 1 - 1
mobile/lib/shared/ui/scaffold_error_body.dart

@@ -15,7 +15,7 @@ class ScaffoldErrorBody extends StatelessWidget {
       mainAxisAlignment: MainAxisAlignment.center,
       children: [
         Text(
-          "scaffold_body_error_occured",
+          "scaffold_body_error_occurred",
           style: context.textTheme.displayMedium,
           textAlign: TextAlign.center,
         ).tr(),

+ 62 - 0
mobile/lib/utils/selection_handlers.dart

@@ -2,12 +2,17 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asset_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/services/asset.service.dart';
 import 'package:immich_mobile/shared/services/share.service.dart';
+import 'package:immich_mobile/shared/ui/date_time_picker.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/shared/ui/location_picker.dart';
 import 'package:immich_mobile/shared/ui/share_dialog.dart';
+import 'package:latlong2/latlong.dart';
 
 void handleShareAssets(
   WidgetRef ref,
@@ -85,3 +90,60 @@ Future<void> handleFavoriteAssets(
     }
   }
 }
+
+Future<void> handleEditDateTime(
+  WidgetRef ref,
+  BuildContext context,
+  List<Asset> selection,
+) async {
+  DateTime? initialDate;
+  String? timeZone;
+  Duration? offset;
+  if (selection.length == 1) {
+    final asset = selection.first;
+    final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
+    final (dt, oft) = assetWithExif.getTZAdjustedTimeAndOffset();
+    initialDate = dt;
+    offset = oft;
+    timeZone = assetWithExif.exifInfo?.timeZone;
+  }
+  final dateTime = await showDateTimePicker(
+    context: context,
+    initialDateTime: initialDate,
+    initialTZ: timeZone,
+    initialTZOffset: offset,
+  );
+  if (dateTime == null) {
+    return;
+  }
+
+  ref.read(assetServiceProvider).changeDateTime(selection.toList(), dateTime);
+}
+
+Future<void> handleEditLocation(
+  WidgetRef ref,
+  BuildContext context,
+  List<Asset> selection,
+) async {
+  LatLng? initialLatLng;
+  if (selection.length == 1) {
+    final asset = selection.first;
+    final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
+    if (assetWithExif.exifInfo?.latitude != null &&
+        assetWithExif.exifInfo?.longitude != null) {
+      initialLatLng = LatLng(
+        assetWithExif.exifInfo!.latitude!,
+        assetWithExif.exifInfo!.longitude!,
+      );
+    }
+  }
+  final location = await showLocationPicker(
+    context: context,
+    initialLatLng: initialLatLng,
+  );
+  if (location == null) {
+    return;
+  }
+
+  ref.read(assetServiceProvider).changeLocation(selection.toList(), location);
+}

+ 131 - 0
mobile/test/asset_extensions_test.dart

@@ -0,0 +1,131 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_mobile/extensions/asset_extensions.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/exif_info.dart';
+import 'package:timezone/data/latest.dart';
+import 'package:timezone/timezone.dart';
+
+ExifInfo makeExif({
+  DateTime? dateTimeOriginal,
+  String? timeZone,
+}) {
+  return ExifInfo(
+    dateTimeOriginal: dateTimeOriginal,
+    timeZone: timeZone,
+  );
+}
+
+Asset makeAsset({
+  required String id,
+  required DateTime createdAt,
+  ExifInfo? exifInfo,
+}) {
+  return Asset(
+    checksum: '',
+    localId: id,
+    remoteId: id,
+    ownerId: 1,
+    fileCreatedAt: createdAt,
+    fileModifiedAt: DateTime.now(),
+    updatedAt: DateTime.now(),
+    durationInSeconds: 0,
+    type: AssetType.image,
+    fileName: id,
+    isFavorite: false,
+    isArchived: false,
+    isTrashed: false,
+    stackCount: 0,
+    exifInfo: exifInfo,
+  );
+}
+
+void main() {
+  // Init Timezone DB
+  initializeTimeZones();
+
+  group("Returns local time and offset if no exifInfo", () {
+    test('returns createdAt directly if in local', () {
+      final createdAt = DateTime(2023, 12, 12, 12, 12, 12);
+      final a = makeAsset(id: '1', createdAt: createdAt);
+      final (dt, tz) = a.getTZAdjustedTimeAndOffset();
+
+      expect(dt, createdAt);
+      expect(tz, createdAt.timeZoneOffset);
+    });
+
+    test('returns createdAt in local if in utc', () {
+      final createdAt = DateTime.utc(2023, 12, 12, 12, 12, 12);
+      final a = makeAsset(id: '1', createdAt: createdAt);
+      final (dt, tz) = a.getTZAdjustedTimeAndOffset();
+
+      final localCreatedAt = createdAt.toLocal();
+      expect(dt, localCreatedAt);
+      expect(tz, localCreatedAt.timeZoneOffset);
+    });
+  });
+
+  group("Returns dateTimeOriginal", () {
+    test('Returns dateTimeOriginal in UTC from exifInfo without timezone', () {
+      final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
+      final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
+      final e = makeExif(dateTimeOriginal: dateTimeOriginal);
+      final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
+      final (dt, tz) = a.getTZAdjustedTimeAndOffset();
+
+      final dateTimeInUTC = dateTimeOriginal.toUtc();
+      expect(dt, dateTimeInUTC);
+      expect(tz, dateTimeInUTC.timeZoneOffset);
+    });
+
+    test('Returns dateTimeOriginal in UTC from exifInfo with invalid timezone',
+        () {
+      final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
+      final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
+      final e = makeExif(
+        dateTimeOriginal: dateTimeOriginal,
+        timeZone: "#_#",
+      ); // Invalid timezone
+      final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
+      final (dt, tz) = a.getTZAdjustedTimeAndOffset();
+
+      final dateTimeInUTC = dateTimeOriginal.toUtc();
+      expect(dt, dateTimeInUTC);
+      expect(tz, dateTimeInUTC.timeZoneOffset);
+    });
+  });
+
+  group("Returns adjusted time if timezone available", () {
+    test('With timezone as location', () {
+      final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
+      final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
+      const location = "Asia/Hong_Kong";
+      final e =
+          makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: location);
+      final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
+      final (dt, tz) = a.getTZAdjustedTimeAndOffset();
+
+      final adjustedTime =
+          TZDateTime.from(dateTimeOriginal.toUtc(), getLocation(location));
+      expect(dt, adjustedTime);
+      expect(tz, adjustedTime.timeZoneOffset);
+    });
+
+    test('With timezone as offset', () {
+      final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
+      final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
+      const offset = "utc+08:00";
+      final e = makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: offset);
+      final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
+      final (dt, tz) = a.getTZAdjustedTimeAndOffset();
+
+      final location = getLocation("Asia/Hong_Kong");
+      final offsetFromLocation =
+          Duration(milliseconds: location.currentTimeZone.offset);
+      final adjustedTime = dateTimeOriginal.toUtc().add(offsetFromLocation);
+
+      // Adds the offset to the actual time and returns the offset separately
+      expect(dt, adjustedTime);
+      expect(tz, offsetFromLocation);
+    });
+  });
+}