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 now = DateTime.now(); // Handles cases where the date from the asset is far off in the future final initialDate = date.value.isAfter(now) ? now : date.value; final newDate = await showDatePicker( context: context, initialDate: initialDate, firstDate: DateTime(1800), lastDate: 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; }