diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 9450b4b44f..194871e18d 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -248,6 +248,7 @@ "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", "end_date": "End date", diff --git a/mobile/lib/widgets/common/date_time_picker.dart b/mobile/lib/widgets/common/date_time_picker.dart index d90ee40e47..4e4e24e18c 100644 --- a/mobile/lib/widgets/common/date_time_picker.dart +++ b/mobile/lib/widgets/common/date_time_picker.dart @@ -4,6 +4,7 @@ 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'; @@ -24,7 +25,7 @@ Future showDateTimePicker({ } 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 { @@ -73,7 +74,6 @@ class _DateTimePicker extends HookWidget { // 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(); @@ -133,83 +133,78 @@ class _DateTimePicker extends HookWidget { context.pop(dtWithOffset); } - return LayoutBuilder( - builder: (context, constraint) => AlertDialog( - contentPadding: - const EdgeInsets.symmetric(vertical: 32, horizontal: 18), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text( - "action_common_cancel", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.colorScheme.error, + return AlertDialog( + contentPadding: const EdgeInsets.symmetric(vertical: 32, horizontal: 18), + 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(), + ), + ], + 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, ), - TextButton( - onPressed: popWithDateTime, - child: Text( - "action_common_update", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, - ), - ).tr(), + const SizedBox(height: 24), + DropdownSearchMenu( + 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, ), ], - 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, - ), - ], - ), ), ); } diff --git a/mobile/lib/widgets/common/dropdown_search_menu.dart b/mobile/lib/widgets/common/dropdown_search_menu.dart new file mode 100644 index 0000000000..2fd5539b01 --- /dev/null +++ b/mobile/lib/widgets/common/dropdown_search_menu.dart @@ -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 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> dropdownMenuEntries; + final T? initialSelection; + final ValueChanged? onSelected; + final Widget? trailingIcon; + final String? hintText; + final Widget? label; + final TextStyle? textStyle; + final BoxConstraints? menuConstraints; + + @override + Widget build(BuildContext context) { + final selectedItem = useState?>( + dropdownMenuEntries + .firstWhereOrNull((item) => item.value == initialSelection), + ); + final showTimeZoneDropdown = useState(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>( + 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, + ), + ); + }, + ), + ); + }, + ), + ), + ), + ); + }, + ), + ); + } +}