diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties index 98bcc01e4..7787882b7 100644 --- a/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ b/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 \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80 \ No newline at end of file diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 6d28890eb..93436ab4e 100644 --- a/mobile/assets/i18n/en-US.json +++ b/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" } diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart new file mode 100644 index 000000000..a755792bc --- /dev/null +++ b/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); + } +} diff --git a/mobile/lib/extensions/duration_extensions.dart b/mobile/lib/extensions/duration_extensions.dart new file mode 100644 index 000000000..68fb1b068 --- /dev/null +++ b/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')}"; +} diff --git a/mobile/lib/modules/activities/providers/activity.provider.dart b/mobile/lib/modules/activities/providers/activity.provider.dart index c0fa5e628..9d8a3429b 100644 --- a/mobile/lib/modules/activities/providers/activity.provider.dart +++ b/mobile/lib/modules/activities/providers/activity.provider.dart @@ -95,7 +95,11 @@ class ActivityStatisticsNotifier extends StateNotifier { } Future fetchStatistics() async { - state = await _activityService.getStatistics(albumId, assetId: assetId); + final count = + await _activityService.getStatistics(albumId, assetId: assetId); + if (mounted) { + state = count; + } } Future addActivity() async { diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index 08a6a0515..8c63c9161 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/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,111 +22,84 @@ 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); - } - } - } - } - - final date = DateFormat.yMMMEd().format(dt); - final time = DateFormat.jm().format(dt); - timeZone ??= formatTimeZone(dt.timeZoneOffset); - - return '$date • $time $timeZone'; - } - - Future _createCoordinatesUri(ExifInfo? exifInfo) async { - if (!hasCoordinates(exifInfo)) { - return null; - } - - final double latitude = exifInfo!.latitude!; - final double longitude = exifInfo.longitude!; - - const zoomLevel = 16; - - 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', - }; - 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; + bool hasCoordinates() => + exifInfo != null && + exifInfo.latitude != null && + exifInfo.longitude != null && + exifInfo.latitude != 0 && + exifInfo.longitude != 0; + + String formattedDateTime() { + final (dt, timeZone) = + (assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset(); + final date = DateFormat.yMMMEd().format(dt); + final time = DateFormat.jm().format(dt); + + return '$date • $time GMT${timeZone.formatAsOffset()}'; + } + + Future createCoordinatesUri() async { + if (!hasCoordinates()) { + return null; + } + + final double latitude = exifInfo!.latitude!; + final double longitude = exifInfo.longitude!; + + const zoomLevel = 16; + + 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', + }; + 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', + ); + } + buildMap() { return Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), 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), ], diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 8ae7f98cd..6b0b91c33 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/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 albums; final List 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(), diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 58770ed5c..c351d5708 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/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 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, ), ], ), diff --git a/mobile/lib/modules/map/ui/map_location_picker.dart b/mobile/lib/modules/map/ui/map_location_picker.dart new file mode 100644 index 000000000..c3a2043ae --- /dev/null +++ b/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(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(), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/map/ui/map_thumbnail.dart b/mobile/lib/modules/map/ui/map_thumbnail.dart index d42d99de1..e385eb970 100644 --- a/mobile/lib/modules/map/ui/map_thumbnail.dart +++ b/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 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) diff --git a/mobile/lib/modules/map/utils/map_controller_hook.dart b/mobile/lib/modules/map/utils/map_controller_hook.dart new file mode 100644 index 000000000..e5812c938 --- /dev/null +++ b/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? keys, +}) { + return use(_MapControllerHook(keys: keys)); +} + +class _MapControllerHook extends Hook { + const _MapControllerHook({List? keys}) : super(keys: keys); + + @override + HookState> createState() => + _MapControllerHookState(); +} + +class _MapControllerHookState + extends HookState { + late final controller = MapController(); + + @override + MapController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useMapController'; +} diff --git a/mobile/lib/modules/map/views/map_page.dart b/mobile/lib/modules/map/views/map_page.dart index b03c13e36..697ea41e0 100644 --- a/mobile/lib/modules/map/views/map_page.dart +++ b/mobile/lib/modules/map/views/map_page.dart @@ -55,6 +55,7 @@ class MapPageState extends ConsumerState { // 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 { 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 { minZoom: 1, maxZoom: maxZoom, onMapReady: () { + isMapReady = true; mapController.mapEventStream.listen(onMapEvent); }, ), diff --git a/mobile/lib/modules/search/ui/curated_places_row.dart b/mobile/lib/modules/search/ui/curated_places_row.dart index b0343f5ed..133c0e1c8 100644 --- a/mobile/lib/modules/search/ui/curated_places_row.dart +++ b/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, ), diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 67407b7e2..0773f7aa0 100644 --- a/mobile/lib/routing/router.dart +++ b/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( + page: MapLocationPickerPage, + guards: [AuthGuard, DuplicateGuard], + ), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d54f8aeda..05980054f 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -360,6 +360,19 @@ class _$AppRouter extends RootStackRouter { barrierDismissible: false, ); }, + MapLocationPickerRoute.name: (routeData) { + final args = routeData.argsAs( + orElse: () => const MapLocationPickerRouteArgs()); + return CustomPage( + routeData: routeData, + child: MapLocationPickerPage( + key: args.key, + initialLatLng: args.initialLatLng, + ), + opaque: true, + barrierDismissible: false, + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( 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 { + 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 { diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 31b243e4d..68234dab4 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/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 && diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 8b1ee6a33..a7bb4f019 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/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> changeArchiveStatus(List assets, bool isArchive) { return updateAssets(assets, UpdateAssetDto(isArchived: isArchive)); } + + Future> changeDateTime( + List assets, + String updatedDt, + ) { + return updateAssets( + assets, + UpdateAssetDto(dateTimeOriginal: updatedDt), + ); + } + + Future> changeLocation( + List assets, + LatLng location, + ) { + return updateAssets( + assets, + UpdateAssetDto( + latitude: location.latitude, + longitude: location.longitude, + ), + ); + } } diff --git a/mobile/lib/shared/ui/date_time_picker.dart b/mobile/lib/shared/ui/date_time_picker.dart new file mode 100644 index 000000000..6c7ea16ce --- /dev/null +++ b/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 showDateTimePicker({ + required BuildContext context, + DateTime? initialDateTime, + String? initialTZ, + Duration? initialTZOffset, +}) { + return showDialog( + 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 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(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; +} diff --git a/mobile/lib/shared/ui/location_picker.dart b/mobile/lib/shared/ui/location_picker.dart new file mode 100644 index 000000000..9649c36ad --- /dev/null +++ b/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 showLocationPicker({ + required BuildContext context, + LatLng? initialLatLng, +}) { + return showDialog( + 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 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( + 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 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(), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/ui/scaffold_error_body.dart b/mobile/lib/shared/ui/scaffold_error_body.dart index fef6bef59..ef0d9d599 100644 --- a/mobile/lib/shared/ui/scaffold_error_body.dart +++ b/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(), diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index d52f3ac1d..47bf33e98 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/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 handleFavoriteAssets( } } } + +Future handleEditDateTime( + WidgetRef ref, + BuildContext context, + List 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 handleEditLocation( + WidgetRef ref, + BuildContext context, + List 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); +} diff --git a/mobile/test/asset_extensions_test.dart b/mobile/test/asset_extensions_test.dart new file mode 100644 index 000000000..1e429b5ac --- /dev/null +++ b/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); + }); + }); +}