date_time_picker.dart 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  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 now = DateTime.now();
  79. // Handles cases where the date from the asset is far off in the future
  80. final initialDate = date.value.isAfter(now) ? now : date.value;
  81. final newDate = await showDatePicker(
  82. context: context,
  83. initialDate: initialDate,
  84. firstDate: DateTime(1800),
  85. lastDate: now,
  86. );
  87. if (newDate == null) {
  88. return;
  89. }
  90. final newTime = await showTimePicker(
  91. context: context,
  92. initialTime: TimeOfDay.fromDateTime(date.value),
  93. );
  94. if (newTime == null) {
  95. return;
  96. }
  97. date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute);
  98. }
  99. void popWithDateTime() {
  100. final formattedDateTime =
  101. DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value);
  102. final dtWithOffset = formattedDateTime +
  103. Duration(milliseconds: tzOffset.value.offsetInMilliseconds)
  104. .formatAsOffset();
  105. context.pop(dtWithOffset);
  106. }
  107. return AlertDialog(
  108. contentPadding: const EdgeInsets.all(30),
  109. alignment: Alignment.center,
  110. content: Column(
  111. mainAxisSize: MainAxisSize.min,
  112. children: [
  113. const Text(
  114. "edit_date_time_dialog_date_time",
  115. textAlign: TextAlign.center,
  116. ).tr(),
  117. TextButton.icon(
  118. onPressed: pickDate,
  119. icon: Text(
  120. DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
  121. style: context.textTheme.bodyLarge
  122. ?.copyWith(color: context.primaryColor),
  123. ),
  124. label: const Icon(
  125. Icons.edit_outlined,
  126. size: 18,
  127. ),
  128. ),
  129. const Text(
  130. "edit_date_time_dialog_timezone",
  131. textAlign: TextAlign.center,
  132. ).tr(),
  133. DropdownMenu(
  134. menuHeight: 300,
  135. width: 280,
  136. inputDecorationTheme: const InputDecorationTheme(
  137. border: InputBorder.none,
  138. contentPadding: EdgeInsets.zero,
  139. ),
  140. trailingIcon: Padding(
  141. padding: const EdgeInsets.only(right: 10),
  142. child: Icon(
  143. Icons.arrow_drop_down,
  144. color: context.primaryColor,
  145. ),
  146. ),
  147. textStyle: context.textTheme.bodyLarge?.copyWith(
  148. color: context.primaryColor,
  149. ),
  150. menuStyle: const MenuStyle(
  151. fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)),
  152. alignment: Alignment(-1.25, 0.5),
  153. ),
  154. onSelected: (value) => tzOffset.value = value!,
  155. initialSelection: tzOffset.value,
  156. dropdownMenuEntries: timeZones
  157. .map(
  158. (t) => DropdownMenuEntry<_TimeZoneOffset>(
  159. value: t,
  160. label: t.display,
  161. style: ButtonStyle(
  162. textStyle: MaterialStatePropertyAll(
  163. context.textTheme.bodyMedium,
  164. ),
  165. ),
  166. ),
  167. )
  168. .toList(),
  169. ),
  170. ],
  171. ),
  172. actions: [
  173. TextButton(
  174. onPressed: () => context.pop(),
  175. child: Text(
  176. "action_common_cancel",
  177. style: context.textTheme.bodyMedium?.copyWith(
  178. fontWeight: FontWeight.w600,
  179. color: context.colorScheme.error,
  180. ),
  181. ).tr(),
  182. ),
  183. TextButton(
  184. onPressed: popWithDateTime,
  185. child: Text(
  186. "action_common_update",
  187. style: context.textTheme.bodyMedium?.copyWith(
  188. fontWeight: FontWeight.w600,
  189. color: context.primaryColor,
  190. ),
  191. ).tr(),
  192. ),
  193. ],
  194. );
  195. }
  196. }
  197. class _TimeZoneOffset implements Comparable<_TimeZoneOffset> {
  198. final String display;
  199. final Location location;
  200. const _TimeZoneOffset({
  201. required this.display,
  202. required this.location,
  203. });
  204. _TimeZoneOffset copyWith({
  205. String? display,
  206. Location? location,
  207. }) {
  208. return _TimeZoneOffset(
  209. display: display ?? this.display,
  210. location: location ?? this.location,
  211. );
  212. }
  213. int get offsetInMilliseconds => location.currentTimeZone.offset;
  214. _TimeZoneOffset.fromLocation(tz.Location l)
  215. : display = _getFormattedOffset(l.currentTimeZone.offset, l),
  216. location = l;
  217. @override
  218. int compareTo(_TimeZoneOffset other) {
  219. return offsetInMilliseconds.compareTo(other.offsetInMilliseconds);
  220. }
  221. @override
  222. String toString() =>
  223. '_TimeZoneOffset(display: $display, location: $location)';
  224. @override
  225. bool operator ==(Object other) {
  226. if (identical(this, other)) return true;
  227. return other is _TimeZoneOffset &&
  228. other.display == display &&
  229. other.offsetInMilliseconds == offsetInMilliseconds;
  230. }
  231. @override
  232. int get hashCode =>
  233. display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode;
  234. }