date_time_picker.dart 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import 'package:collection/collection.dart';
  2. import 'package:easy_localization/easy_localization.dart';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter_hooks/flutter_hooks.dart';
  5. import 'package:immich_mobile/extensions/build_context_extensions.dart';
  6. import 'package:immich_mobile/extensions/duration_extensions.dart';
  7. import 'package:timezone/timezone.dart' as tz;
  8. import 'package:timezone/timezone.dart';
  9. Future<String?> showDateTimePicker({
  10. required BuildContext context,
  11. DateTime? initialDateTime,
  12. String? initialTZ,
  13. Duration? initialTZOffset,
  14. }) {
  15. return showDialog<String?>(
  16. context: context,
  17. builder: (context) => _DateTimePicker(
  18. initialDateTime: initialDateTime,
  19. initialTZ: initialTZ,
  20. initialTZOffset: initialTZOffset,
  21. ),
  22. );
  23. }
  24. String _getFormattedOffset(int offsetInMilli, tz.Location location) {
  25. return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
  26. }
  27. class _DateTimePicker extends HookWidget {
  28. final DateTime? initialDateTime;
  29. final String? initialTZ;
  30. final Duration? initialTZOffset;
  31. const _DateTimePicker({
  32. this.initialDateTime,
  33. this.initialTZ,
  34. this.initialTZOffset,
  35. });
  36. _TimeZoneOffset _getInitiationLocation() {
  37. if (initialTZ != null) {
  38. try {
  39. return _TimeZoneOffset.fromLocation(
  40. tz.timeZoneDatabase.get(initialTZ!),
  41. );
  42. } on LocationNotFoundException {
  43. // no-op
  44. }
  45. }
  46. Duration? tzOffset = initialTZOffset ?? initialDateTime?.timeZoneOffset;
  47. if (tzOffset != null) {
  48. final offsetInMilli = tzOffset.inMilliseconds;
  49. // get all locations with matching offset
  50. final locations = tz.timeZoneDatabase.locations.values.where(
  51. (location) => location.currentTimeZone.offset == offsetInMilli,
  52. );
  53. // Prefer locations with abbreviation first
  54. final location = locations.firstWhereOrNull(
  55. (e) => !e.currentTimeZone.abbreviation.contains("0"),
  56. ) ??
  57. locations.firstOrNull;
  58. if (location != null) {
  59. return _TimeZoneOffset.fromLocation(location);
  60. }
  61. }
  62. return _TimeZoneOffset.fromLocation(tz.getLocation("UTC"));
  63. }
  64. // returns a list of location<name> along with it's offset in duration
  65. List<_TimeZoneOffset> getAllTimeZones() {
  66. return tz.timeZoneDatabase.locations.values
  67. .where((l) => !l.currentTimeZone.abbreviation.contains("0"))
  68. .map(_TimeZoneOffset.fromLocation)
  69. .sorted()
  70. .toList();
  71. }
  72. @override
  73. Widget build(BuildContext context) {
  74. final date = useState<DateTime>(initialDateTime ?? DateTime.now());
  75. final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation());
  76. final timeZones = useMemoized(() => getAllTimeZones(), const []);
  77. void pickDate() async {
  78. final newDate = await showDatePicker(
  79. context: context,
  80. initialDate: date.value,
  81. firstDate: DateTime(1800),
  82. lastDate: DateTime.now(),
  83. );
  84. if (newDate == null) {
  85. return;
  86. }
  87. final newTime = await showTimePicker(
  88. context: context,
  89. initialTime: TimeOfDay.fromDateTime(date.value),
  90. );
  91. if (newTime == null) {
  92. return;
  93. }
  94. date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute);
  95. }
  96. void popWithDateTime() {
  97. final formattedDateTime =
  98. DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value);
  99. final dtWithOffset = formattedDateTime +
  100. Duration(milliseconds: tzOffset.value.offsetInMilliseconds)
  101. .formatAsOffset();
  102. context.pop(dtWithOffset);
  103. }
  104. return AlertDialog(
  105. contentPadding: const EdgeInsets.all(30),
  106. alignment: Alignment.center,
  107. content: Column(
  108. mainAxisSize: MainAxisSize.min,
  109. children: [
  110. const Text(
  111. "edit_date_time_dialog_date_time",
  112. textAlign: TextAlign.center,
  113. ).tr(),
  114. TextButton.icon(
  115. onPressed: pickDate,
  116. icon: Text(
  117. DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
  118. style: context.textTheme.bodyLarge
  119. ?.copyWith(color: context.primaryColor),
  120. ),
  121. label: const Icon(
  122. Icons.edit_outlined,
  123. size: 18,
  124. ),
  125. ),
  126. const Text(
  127. "edit_date_time_dialog_timezone",
  128. textAlign: TextAlign.center,
  129. ).tr(),
  130. DropdownMenu(
  131. menuHeight: 300,
  132. width: 280,
  133. inputDecorationTheme: const InputDecorationTheme(
  134. border: InputBorder.none,
  135. contentPadding: EdgeInsets.zero,
  136. ),
  137. trailingIcon: Padding(
  138. padding: const EdgeInsets.only(right: 10),
  139. child: Icon(
  140. Icons.arrow_drop_down,
  141. color: context.primaryColor,
  142. ),
  143. ),
  144. textStyle: context.textTheme.bodyLarge?.copyWith(
  145. color: context.primaryColor,
  146. ),
  147. menuStyle: const MenuStyle(
  148. fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)),
  149. alignment: Alignment(-1.25, 0.5),
  150. ),
  151. onSelected: (value) => tzOffset.value = value!,
  152. initialSelection: tzOffset.value,
  153. dropdownMenuEntries: timeZones
  154. .map(
  155. (t) => DropdownMenuEntry<_TimeZoneOffset>(
  156. value: t,
  157. label: t.display,
  158. style: ButtonStyle(
  159. textStyle: MaterialStatePropertyAll(
  160. context.textTheme.bodyMedium,
  161. ),
  162. ),
  163. ),
  164. )
  165. .toList(),
  166. ),
  167. ],
  168. ),
  169. actions: [
  170. TextButton(
  171. onPressed: () => context.pop(),
  172. child: Text(
  173. "action_common_cancel",
  174. style: context.textTheme.bodyMedium?.copyWith(
  175. fontWeight: FontWeight.w600,
  176. color: context.colorScheme.error,
  177. ),
  178. ).tr(),
  179. ),
  180. TextButton(
  181. onPressed: popWithDateTime,
  182. child: Text(
  183. "action_common_update",
  184. style: context.textTheme.bodyMedium?.copyWith(
  185. fontWeight: FontWeight.w600,
  186. color: context.primaryColor,
  187. ),
  188. ).tr(),
  189. ),
  190. ],
  191. );
  192. }
  193. }
  194. class _TimeZoneOffset implements Comparable<_TimeZoneOffset> {
  195. final String display;
  196. final Location location;
  197. const _TimeZoneOffset({
  198. required this.display,
  199. required this.location,
  200. });
  201. _TimeZoneOffset copyWith({
  202. String? display,
  203. Location? location,
  204. }) {
  205. return _TimeZoneOffset(
  206. display: display ?? this.display,
  207. location: location ?? this.location,
  208. );
  209. }
  210. int get offsetInMilliseconds => location.currentTimeZone.offset;
  211. _TimeZoneOffset.fromLocation(tz.Location l)
  212. : display = _getFormattedOffset(l.currentTimeZone.offset, l),
  213. location = l;
  214. @override
  215. int compareTo(_TimeZoneOffset other) {
  216. return offsetInMilliseconds.compareTo(other.offsetInMilliseconds);
  217. }
  218. @override
  219. String toString() =>
  220. '_TimeZoneOffset(display: $display, location: $location)';
  221. @override
  222. bool operator ==(Object other) {
  223. if (identical(this, other)) return true;
  224. return other is _TimeZoneOffset &&
  225. other.display == display &&
  226. other.offsetInMilliseconds == offsetInMilliseconds;
  227. }
  228. @override
  229. int get hashCode =>
  230. display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode;
  231. }