diff --git a/i18n/en.json b/i18n/en.json index 0c479b0738..f64a55e37f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1175,6 +1175,7 @@ "in_albums": "In {count, plural, one {# album} other {# albums}}", "in_archive": "In archive", "in_year": "In {year}", + "in_year_selector": "In", "include_archived": "Include archived", "include_shared_albums": "Include shared albums", "include_shared_partner_assets": "Include shared partner assets", diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 9e385a9ef2..e8d914fc9a 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -54,6 +54,7 @@ class DriftSearchPage extends HookConsumerWidget { ); final previousFilter = useState(null); + final dateInputFilter = useState(null); final peopleCurrentFilterWidget = useState(null); final dateRangeCurrentFilterWidget = useState(null); @@ -243,8 +244,9 @@ class DriftSearchPage extends HookConsumerWidget { ); } - datePicked(DateTimeRange? date) { - if (date == null) { + datePicked(DateFilterInputModel? selectedDate) { + dateInputFilter.value = selectedDate; + if (selectedDate == null) { filter.value = filter.value.copyWith(date: SearchDateFilter()); dateRangeCurrentFilterWidget.value = null; @@ -252,6 +254,8 @@ class DriftSearchPage extends HookConsumerWidget { return; } + final date = selectedDate.asDateTimeRange(); + filter.value = filter.value.copyWith( date: SearchDateFilter( takenAfter: date.start, @@ -259,24 +263,10 @@ class DriftSearchPage extends HookConsumerWidget { ), ); - // If date range is less than 24 hours, set the end date to the end of the day - if (date.end.difference(date.start).inHours < 24) { - dateRangeCurrentFilterWidget.value = Text( - DateFormat.yMMMd().format(date.start.toLocal()), - style: context.textTheme.labelLarge, - ); - } else { - dateRangeCurrentFilterWidget.value = Text( - 'search_filter_date_interval'.t( - context: context, - args: { - "start": DateFormat.yMMMd().format(date.start.toLocal()), - "end": DateFormat.yMMMd().format(date.end.toLocal()), - }, - ), - style: context.textTheme.labelLarge, - ); - } + dateRangeCurrentFilterWidget.value = Text( + selectedDate.asHumanReadable(context), + style: context.textTheme.labelLarge, + ); search(); } @@ -314,7 +304,11 @@ class DriftSearchPage extends HookConsumerWidget { keyboardType: TextInputType.text, ); - datePicked(date); + if (date == null) { + datePicked(null); + } else { + datePicked(CustomDateFilter.fromRange(date)); + } } showQuickDatePicker() { @@ -325,6 +319,7 @@ class DriftSearchPage extends HookConsumerWidget { expanded: true, onClear: () => datePicked(null), child: QuickDatePicker( + currentRange: dateInputFilter.value, onRequestPicker: () { context.pop(); showDatePicker(); diff --git a/mobile/lib/presentation/widgets/search/quick_date_picker.dart b/mobile/lib/presentation/widgets/search/quick_date_picker.dart index b10b612be7..425e7bebc7 100644 --- a/mobile/lib/presentation/widgets/search/quick_date_picker.dart +++ b/mobile/lib/presentation/widgets/search/quick_date_picker.dart @@ -3,68 +3,203 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -class QuickDatePicker extends HookWidget { - const QuickDatePicker({super.key, required this.onSelect, required this.onRequestPicker}); +sealed class DateFilterInputModel { + DateTimeRange asDateTimeRange(); - final Function() onRequestPicker; - final Function(DateTimeRange range) onSelect; - - void _selectRange(DateTimeRange range) { - // Ensure we don't go beyond today, eg when picking "in $current_year" - final now = DateTime.now(); - if (range.end.isAfter(now)) { - range = DateTimeRange(start: range.start, end: now); + String asHumanReadable(BuildContext context) { + // Generail implementation for arbitrary date and time ranges + // If date range is less than 24 hours, set the end date to the end of the day + final date = asDateTimeRange(); + if (date.end.difference(date.start).inHours < 24) { + return DateFormat.yMMMd().format(date.start.toLocal()); + } else { + return 'search_filter_date_interval'.t( + context: context, + args: { + "start": DateFormat.yMMMd().format(date.start.toLocal()), + "end": DateFormat.yMMMd().format(date.end.toLocal()), + }, + ); } - - onSelect(range); } +} - ListTile _monthListTile(BuildContext context, int monthsFromNow) { - String label = 'last_months'.t(context: context, args: {"count": monthsFromNow.toString()}); - return ListTile( - title: Text(label), - onTap: () { - final now = DateTime.now(); - // We use the first of the target month here to avoid issues with different month lengths - // the negative overflow of months is handled by DateTime correctly - final from = DateTime(now.year, now.month - monthsFromNow, 1); - _selectRange(DateTimeRange(start: from, end: now)); - }, - ); +class RecentMonthRangeFilter extends DateFilterInputModel { + final int monthDelta; + RecentMonthRangeFilter(this.monthDelta); + + @override + DateTimeRange asDateTimeRange() { + final now = DateTime.now(); + // Note that DateTime's constructor properly handles month overflow. + final from = DateTime(now.year, now.month - monthDelta, 1); + return DateTimeRange(start: from, end: now); } + @override + String asHumanReadable(BuildContext context) { + return 'last_months'.t(context: context, args: {"count": monthDelta.toString()}); + } +} + +class YearFilter extends DateFilterInputModel { + final int year; + YearFilter(this.year); + + @override + DateTimeRange asDateTimeRange() { + final now = DateTime.now(); + final from = DateTime(year, 1, 1); + + if (now.year == year) { + // To not go beyond today if the user picks the current year + return DateTimeRange(start: from, end: now); + } + + final to = DateTime(year, 12, 31, 23, 59, 59); + return DateTimeRange(start: from, end: to); + } + + @override + String asHumanReadable(BuildContext context) { + return 'in_year'.tr(namedArgs: {"year": year.toString()}); + } +} + +class CustomDateFilter extends DateFilterInputModel { + final DateTime start; + final DateTime end; + + CustomDateFilter(this.start, this.end); + + factory CustomDateFilter.fromRange(DateTimeRange range) { + return CustomDateFilter(range.start, range.end); + } + + @override + DateTimeRange asDateTimeRange() { + return DateTimeRange(start: start, end: end); + } +} + +enum _QuickPickerType { last1Month, last3Months, last9Months, year, custom } + +class QuickDatePicker extends HookWidget { + QuickDatePicker({super.key, required this.currentRange, required this.onSelect, required this.onRequestPicker}) + : _selection = _inputModelToType(currentRange), + _initialYear = _initialYearFromModel(currentRange); + + final Function() onRequestPicker; + final Function(DateFilterInputModel range) onSelect; + final DateFilterInputModel? currentRange; + final _QuickPickerType? _selection; + final int _initialYear; + + // Generate a list of recent years from 2000 to the current year (including the current one) + final List _recentYears = List.generate(1 + DateTime.now().year - 2000, (index) { + return index + 2000; + }); + + static int _initialYearFromModel(DateFilterInputModel? model) { + return model?.asDateTimeRange().start.year ?? DateTime.now().year; + } + + static _QuickPickerType? _inputModelToType(DateFilterInputModel? model) { + if (model is RecentMonthRangeFilter) { + switch (model.monthDelta) { + case 1: + return _QuickPickerType.last1Month; + case 3: + return _QuickPickerType.last3Months; + case 6: + return _QuickPickerType.last9Months; + default: + return _QuickPickerType.custom; + } + } else if (model is YearFilter) { + return _QuickPickerType.year; + } else if (model is CustomDateFilter) { + return _QuickPickerType.custom; + } + return null; + } + + Text _monthLabel(BuildContext context, int monthsFromNow) => + const Text('last_months').t(context: context, args: {"count": monthsFromNow.toString()}); + + Widget _yearPicker(BuildContext context) { + final size = MediaQuery.of(context).size; + return Row( + children: [ + const Text("in_year_selector").tr(), + const SizedBox(width: 15), + Expanded( + child: DropdownMenu( + initialSelection: _initialYear, + menuStyle: MenuStyle(maximumSize: WidgetStateProperty.all(Size(size.width, size.height * 0.5))), + dropdownMenuEntries: _recentYears.map((e) => DropdownMenuEntry(value: e, label: e.toString())).toList(), + onSelected: (year) { + if (year == null) return; + onSelect(YearFilter(year)); + }, + ), + ), + ], + ); + } + + // We want the exact date picker to always be selectable. + // Even if it's already toggled it should always open the full date picker, RadioListTiles don't allow that by default so we wrap it in a GestureDetector + Widget _exactPicker(BuildContext context) => InkWell( + onTap: onRequestPicker, + child: IgnorePointer( + ignoring: true, + child: RadioListTile(title: const Text('pick_exact_date').tr(), value: _QuickPickerType.custom, toggleable: true), + ), + ); + @override Widget build(BuildContext context) { - return ListView.builder( - itemBuilder: (context, index) { - if (index == 0) { - return ListTile( - title: Text('pick_exact_date'.tr()), - onTap: () { - onRequestPicker(); - }, - ); - } else if (index == 1) { - return _monthListTile(context, 1); - } else if (index == 2) { - return _monthListTile(context, 3); - } else if (index == 3) { - return _monthListTile(context, 9); - } else { - final now = DateTime.now(); - final years = index - 4; - final year = now.year - years; - return ListTile( - title: Text("in_year".tr(namedArgs: {"year": year.toString()})), - onTap: () { - final from = DateTime(year, 1, 1); - final to = DateTime(year, 12, 31, 23, 59, 59); - _selectRange(DateTimeRange(start: from, end: to)); - }, - ); - } - }, - itemCount: 50, + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + clipBehavior: Clip.none, + child: RadioGroup( + onChanged: (value) { + switch (value) { + case _QuickPickerType.custom: + onRequestPicker(); + break; + case _QuickPickerType.last1Month: + onSelect(RecentMonthRangeFilter(1)); + break; + case _QuickPickerType.last3Months: + onSelect(RecentMonthRangeFilter(3)); + break; + case _QuickPickerType.last9Months: + onSelect(RecentMonthRangeFilter(9)); + break; + case _QuickPickerType.year: + // The combobox triggers the onSelect event on its own so if this is ever selected it can only be on + // the default value set by the constructor + onSelect(YearFilter(_initialYear)); + break; + default: + break; + } + }, + groupValue: _selection, + child: Column( + children: [ + RadioListTile(title: _monthLabel(context, 1), value: _QuickPickerType.last1Month, toggleable: true), + RadioListTile(title: _monthLabel(context, 3), value: _QuickPickerType.last3Months, toggleable: true), + RadioListTile(title: _monthLabel(context, 9), value: _QuickPickerType.last9Months, toggleable: true), + RadioListTile(title: _yearPicker(context), value: _QuickPickerType.year, toggleable: true), + _exactPicker(context), + ], + ), + ), + ), ); } }