mirror of https://github.com/immich-app/immich.git
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>pull/5512/head
parent
84c5b08c25
commit
086a957a2b
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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')}";
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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