mirror of
https://github.com/immich-app/immich.git
synced 2025-03-04 02:11:44 -05:00
fix(mobile): improve timezone picker (#15615)
- Fix missing timezones - Remove the UTC prefix from timezone display text to align with web app - Remove unnecessary layout builder - Created a custom `DropdownSearchMenu` widget Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
d12b1c907d
commit
19f2f888ee
3 changed files with 239 additions and 74 deletions
|
@ -248,6 +248,7 @@
|
||||||
"download_waiting_to_retry": "Waiting to retry",
|
"download_waiting_to_retry": "Waiting to retry",
|
||||||
"edit_date_time_dialog_date_time": "Date and Time",
|
"edit_date_time_dialog_date_time": "Date and Time",
|
||||||
"edit_date_time_dialog_timezone": "Timezone",
|
"edit_date_time_dialog_timezone": "Timezone",
|
||||||
|
"edit_date_time_dialog_search_timezone": "Search timezone...",
|
||||||
"edit_image_title": "Edit",
|
"edit_image_title": "Edit",
|
||||||
"edit_location_dialog_title": "Location",
|
"edit_location_dialog_title": "Location",
|
||||||
"end_date": "End date",
|
"end_date": "End date",
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/duration_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' as tz;
|
||||||
import 'package:timezone/timezone.dart';
|
import 'package:timezone/timezone.dart';
|
||||||
|
|
||||||
|
@ -24,7 +25,7 @@ Future<String?> showDateTimePicker({
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getFormattedOffset(int offsetInMilli, tz.Location location) {
|
String _getFormattedOffset(int offsetInMilli, tz.Location location) {
|
||||||
return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
|
return "${location.name} (${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DateTimePicker extends HookWidget {
|
class _DateTimePicker extends HookWidget {
|
||||||
|
@ -73,7 +74,6 @@ class _DateTimePicker extends HookWidget {
|
||||||
// returns a list of location<name> along with it's offset in duration
|
// returns a list of location<name> along with it's offset in duration
|
||||||
List<_TimeZoneOffset> getAllTimeZones() {
|
List<_TimeZoneOffset> getAllTimeZones() {
|
||||||
return tz.timeZoneDatabase.locations.values
|
return tz.timeZoneDatabase.locations.values
|
||||||
.where((l) => !l.currentTimeZone.abbreviation.contains("0"))
|
|
||||||
.map(_TimeZoneOffset.fromLocation)
|
.map(_TimeZoneOffset.fromLocation)
|
||||||
.sorted()
|
.sorted()
|
||||||
.toList();
|
.toList();
|
||||||
|
@ -133,83 +133,78 @@ class _DateTimePicker extends HookWidget {
|
||||||
context.pop(dtWithOffset);
|
context.pop(dtWithOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
return LayoutBuilder(
|
return AlertDialog(
|
||||||
builder: (context, constraint) => AlertDialog(
|
contentPadding: const EdgeInsets.symmetric(vertical: 32, horizontal: 18),
|
||||||
contentPadding:
|
actions: [
|
||||||
const EdgeInsets.symmetric(vertical: 32, horizontal: 18),
|
TextButton(
|
||||||
actions: [
|
onPressed: () => context.pop(),
|
||||||
TextButton(
|
child: Text(
|
||||||
onPressed: () => context.pop(),
|
"action_common_cancel",
|
||||||
child: Text(
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
"action_common_cancel",
|
fontWeight: FontWeight.w600,
|
||||||
style: context.textTheme.bodyMedium?.copyWith(
|
color: context.colorScheme.error,
|
||||||
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(
|
||||||
|
"edit_date_time_dialog_date_time",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ListTile(
|
||||||
|
tileColor: context.colorScheme.surfaceContainerHighest,
|
||||||
|
shape: ShapeBorder.lerp(
|
||||||
|
RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
|
RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.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,
|
||||||
).tr(),
|
).tr(),
|
||||||
|
onTap: pickDate,
|
||||||
),
|
),
|
||||||
TextButton(
|
const SizedBox(height: 24),
|
||||||
onPressed: popWithDateTime,
|
DropdownSearchMenu(
|
||||||
child: Text(
|
trailingIcon: Icon(
|
||||||
"action_common_update",
|
Icons.arrow_drop_down,
|
||||||
style: context.textTheme.bodyMedium?.copyWith(
|
color: context.primaryColor,
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
color: context.primaryColor,
|
hintText: "edit_date_time_dialog_timezone".tr(),
|
||||||
),
|
label: const Text('edit_date_time_dialog_timezone').tr(),
|
||||||
).tr(),
|
textStyle: context.textTheme.bodyMedium,
|
||||||
|
onSelected: (value) => tzOffset.value = value,
|
||||||
|
initialSelection: tzOffset.value,
|
||||||
|
dropdownMenuEntries: menuEntries,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
"edit_date_time_dialog_date_time",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
).tr(),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
ListTile(
|
|
||||||
tileColor: context.colorScheme.surfaceContainerHighest,
|
|
||||||
shape: ShapeBorder.lerp(
|
|
||||||
RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.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,
|
|
||||||
).tr(),
|
|
||||||
onTap: pickDate,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
DropdownMenu(
|
|
||||||
width: 275,
|
|
||||||
menuHeight: 300,
|
|
||||||
trailingIcon: Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
color: context.primaryColor,
|
|
||||||
),
|
|
||||||
hintText: "edit_date_time_dialog_timezone".tr(),
|
|
||||||
label: const Text('edit_date_time_dialog_timezone').tr(),
|
|
||||||
textStyle: context.textTheme.bodyMedium,
|
|
||||||
onSelected: (value) => tzOffset.value = value!,
|
|
||||||
initialSelection: tzOffset.value,
|
|
||||||
dropdownMenuEntries: menuEntries,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
169
mobile/lib/widgets/common/dropdown_search_menu.dart
Normal file
169
mobile/lib/widgets/common/dropdown_search_menu.dart
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
||||||
|
class DropdownSearchMenu<T> extends HookWidget {
|
||||||
|
const DropdownSearchMenu({
|
||||||
|
super.key,
|
||||||
|
required this.dropdownMenuEntries,
|
||||||
|
this.initialSelection,
|
||||||
|
this.onSelected,
|
||||||
|
this.trailingIcon,
|
||||||
|
this.hintText,
|
||||||
|
this.label,
|
||||||
|
this.textStyle,
|
||||||
|
this.menuConstraints,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<DropdownMenuEntry<T>> dropdownMenuEntries;
|
||||||
|
final T? initialSelection;
|
||||||
|
final ValueChanged<T>? onSelected;
|
||||||
|
final Widget? trailingIcon;
|
||||||
|
final String? hintText;
|
||||||
|
final Widget? label;
|
||||||
|
final TextStyle? textStyle;
|
||||||
|
final BoxConstraints? menuConstraints;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selectedItem = useState<DropdownMenuEntry<T>?>(
|
||||||
|
dropdownMenuEntries
|
||||||
|
.firstWhereOrNull((item) => item.value == initialSelection),
|
||||||
|
);
|
||||||
|
final showTimeZoneDropdown = useState<bool>(false);
|
||||||
|
|
||||||
|
final effectiveConstraints = menuConstraints ??
|
||||||
|
const BoxConstraints(
|
||||||
|
minWidth: 280,
|
||||||
|
maxWidth: 280,
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight: 280,
|
||||||
|
);
|
||||||
|
|
||||||
|
final inputDecoration = InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: trailingIcon,
|
||||||
|
label: label,
|
||||||
|
hintText: hintText,
|
||||||
|
).applyDefaults(context.themeData.inputDecorationTheme);
|
||||||
|
|
||||||
|
if (!showTimeZoneDropdown.value) {
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: effectiveConstraints,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => showTimeZoneDropdown.value = true,
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: inputDecoration,
|
||||||
|
child: selectedItem.value != null
|
||||||
|
? Text(
|
||||||
|
selectedItem.value!.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textStyle,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: effectiveConstraints,
|
||||||
|
child: Autocomplete<DropdownMenuEntry<T>>(
|
||||||
|
displayStringForOption: (option) => option.label,
|
||||||
|
optionsBuilder: (textEditingValue) {
|
||||||
|
return dropdownMenuEntries.where(
|
||||||
|
(item) => item.label
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.contains(textEditingValue.text.toLowerCase().trim()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSelected: (option) {
|
||||||
|
selectedItem.value = option;
|
||||||
|
showTimeZoneDropdown.value = false;
|
||||||
|
onSelected?.call(option.value);
|
||||||
|
},
|
||||||
|
fieldViewBuilder: (context, textEditingController, focusNode, _) {
|
||||||
|
return TextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
controller: textEditingController,
|
||||||
|
decoration: inputDecoration.copyWith(
|
||||||
|
hintText: "edit_date_time_dialog_search_timezone".tr(),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
style: context.textTheme.bodyMedium,
|
||||||
|
expands: false,
|
||||||
|
onTapOutside: (event) {
|
||||||
|
showTimeZoneDropdown.value = false;
|
||||||
|
focusNode.unfocus();
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
showTimeZoneDropdown.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
|
// This widget is a copy of the default implementation.
|
||||||
|
// We have only changed the `constraints` parameter.
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: effectiveConstraints,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4.0,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final option = options.elementAt(index);
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
child: Builder(
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
final bool highlight =
|
||||||
|
AutocompleteHighlightedOption.of(context) ==
|
||||||
|
index;
|
||||||
|
if (highlight) {
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback(
|
||||||
|
(Duration timeStamp) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
context,
|
||||||
|
alignment: 0.5,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
debugLabel: 'AutocompleteOptions.ensureVisible',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
color: highlight
|
||||||
|
? Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withOpacity(0.12)
|
||||||
|
: null,
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
option.label,
|
||||||
|
style: textStyle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue