mirror of
https://github.com/immich-app/immich.git
synced 2025-08-11 09:16:31 -04:00
* feat: drift edit time and date action * feat: add edit button on asset viewer bottom sheet * update localDateTime column in addition to createdAt to keep consistency * fix: dont update local dateTime Server calcs this anyway and it will be synced when the change is applied. We don't use localDateTime on mobile so there is no reason to update this value * fix: padding around edit icon in ListTile Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * chore: format * fix: hide date edit control when asset does not have a remote * fix: pull timezones correctly from image --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
201 lines
7.0 KiB
Dart
201 lines
7.0 KiB
Dart
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:immich_mobile/widgets/common/dropdown_search_menu.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} (${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.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 []);
|
|
final menuEntries = timeZones
|
|
.map(
|
|
(timezone) => DropdownMenuEntry<_TimeZoneOffset>(
|
|
value: timezone,
|
|
label: timezone.display,
|
|
style: ButtonStyle(textStyle: WidgetStatePropertyAll(context.textTheme.bodyMedium)),
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
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.symmetric(vertical: 32, horizontal: 18),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => context.pop(),
|
|
child: Text(
|
|
"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(),
|
|
),
|
|
],
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text("date_and_time", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)).tr(),
|
|
const SizedBox(height: 32),
|
|
ListTile(
|
|
tileColor: context.colorScheme.surfaceContainerHighest,
|
|
shape: ShapeBorder.lerp(
|
|
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
|
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
|
1,
|
|
),
|
|
trailing: Icon(Icons.edit_outlined, size: 18, color: context.primaryColor),
|
|
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium),
|
|
onTap: pickDate,
|
|
),
|
|
const SizedBox(height: 24),
|
|
DropdownSearchMenu(
|
|
trailingIcon: Icon(Icons.arrow_drop_down, color: context.primaryColor),
|
|
hintText: "timezone".tr(),
|
|
label: const Text('timezone').tr(),
|
|
textStyle: context.textTheme.bodyMedium,
|
|
onSelected: (value) => tzOffset.value = value,
|
|
initialSelection: tzOffset.value,
|
|
dropdownMenuEntries: menuEntries,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|