mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
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>
This commit is contained in:
parent
84c5b08c25
commit
086a957a2b
@ -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
|
@ -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
mobile/lib/extensions/asset_extensions.dart
Normal file
36
mobile/lib/extensions/asset_extensions.dart
Normal file
@ -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
mobile/lib/extensions/duration_extensions.dart
Normal file
4
mobile/lib/extensions/duration_extensions.dart
Normal file
@ -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')}";
|
||||
}
|
@ -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 {
|
||||
|
@ -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<Uri?> _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<Uri?> 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),
|
||||
],
|
||||
|
@ -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(),
|
||||
|
@ -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
mobile/lib/modules/map/ui/map_location_picker.dart
Normal file
113
mobile/lib/modules/map/ui/map_location_picker.dart
Normal file
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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
mobile/lib/modules/map/utils/map_controller_hook.dart
Normal file
32
mobile/lib/modules/map/utils/map_controller_hook.dart
Normal file
@ -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';
|
||||
}
|
@ -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);
|
||||
},
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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 {
|
||||
|
@ -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> {
|
||||
|
@ -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 &&
|
||||
|
@ -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
mobile/lib/shared/ui/date_time_picker.dart
Normal file
257
mobile/lib/shared/ui/date_time_picker.dart
Normal file
@ -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
mobile/lib/shared/ui/location_picker.dart
Normal file
256
mobile/lib/shared/ui/location_picker.dart
Normal file
@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
|
@ -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
mobile/test/asset_extensions_test.dart
Normal file
131
mobile/test/asset_extensions_test.dart
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user