Wrap the quick date picker state into its own class, improve the interaction patterns

This commit is contained in:
exelix11 2025-10-13 09:49:31 +00:00 committed by Brandon Wees
parent 78467c078e
commit 0a32c50211
3 changed files with 206 additions and 75 deletions

View file

@ -1175,6 +1175,7 @@
"in_albums": "In {count, plural, one {# album} other {# albums}}", "in_albums": "In {count, plural, one {# album} other {# albums}}",
"in_archive": "In archive", "in_archive": "In archive",
"in_year": "In {year}", "in_year": "In {year}",
"in_year_selector": "In",
"include_archived": "Include archived", "include_archived": "Include archived",
"include_shared_albums": "Include shared albums", "include_shared_albums": "Include shared albums",
"include_shared_partner_assets": "Include shared partner assets", "include_shared_partner_assets": "Include shared partner assets",

View file

@ -54,6 +54,7 @@ class DriftSearchPage extends HookConsumerWidget {
); );
final previousFilter = useState<SearchFilter?>(null); final previousFilter = useState<SearchFilter?>(null);
final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null); final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null); final dateRangeCurrentFilterWidget = useState<Widget?>(null);
@ -243,8 +244,9 @@ class DriftSearchPage extends HookConsumerWidget {
); );
} }
datePicked(DateTimeRange<DateTime>? date) { datePicked(DateFilterInputModel? selectedDate) {
if (date == null) { dateInputFilter.value = selectedDate;
if (selectedDate == null) {
filter.value = filter.value.copyWith(date: SearchDateFilter()); filter.value = filter.value.copyWith(date: SearchDateFilter());
dateRangeCurrentFilterWidget.value = null; dateRangeCurrentFilterWidget.value = null;
@ -252,6 +254,8 @@ class DriftSearchPage extends HookConsumerWidget {
return; return;
} }
final date = selectedDate.asDateTimeRange();
filter.value = filter.value.copyWith( filter.value = filter.value.copyWith(
date: SearchDateFilter( date: SearchDateFilter(
takenAfter: date.start, 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 dateRangeCurrentFilterWidget.value = Text(
if (date.end.difference(date.start).inHours < 24) { selectedDate.asHumanReadable(context),
dateRangeCurrentFilterWidget.value = Text( style: context.textTheme.labelLarge,
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,
);
}
search(); search();
} }
@ -314,7 +304,11 @@ class DriftSearchPage extends HookConsumerWidget {
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
); );
datePicked(date); if (date == null) {
datePicked(null);
} else {
datePicked(CustomDateFilter.fromRange(date));
}
} }
showQuickDatePicker() { showQuickDatePicker() {
@ -325,6 +319,7 @@ class DriftSearchPage extends HookConsumerWidget {
expanded: true, expanded: true,
onClear: () => datePicked(null), onClear: () => datePicked(null),
child: QuickDatePicker( child: QuickDatePicker(
currentRange: dateInputFilter.value,
onRequestPicker: () { onRequestPicker: () {
context.pop(); context.pop();
showDatePicker(); showDatePicker();

View file

@ -3,68 +3,203 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
class QuickDatePicker extends HookWidget { sealed class DateFilterInputModel {
const QuickDatePicker({super.key, required this.onSelect, required this.onRequestPicker}); DateTimeRange<DateTime> asDateTimeRange();
final Function() onRequestPicker; String asHumanReadable(BuildContext context) {
final Function(DateTimeRange<DateTime> range) onSelect; // 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
void _selectRange(DateTimeRange range) { final date = asDateTimeRange();
// Ensure we don't go beyond today, eg when picking "in $current_year" if (date.end.difference(date.start).inHours < 24) {
final now = DateTime.now(); return DateFormat.yMMMd().format(date.start.toLocal());
if (range.end.isAfter(now)) { } else {
range = DateTimeRange(start: range.start, end: now); 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) { class RecentMonthRangeFilter extends DateFilterInputModel {
String label = 'last_months'.t(context: context, args: {"count": monthsFromNow.toString()}); final int monthDelta;
return ListTile( RecentMonthRangeFilter(this.monthDelta);
title: Text(label),
onTap: () { @override
final now = DateTime.now(); DateTimeRange<DateTime> asDateTimeRange() {
// We use the first of the target month here to avoid issues with different month lengths final now = DateTime.now();
// the negative overflow of months is handled by DateTime correctly // Note that DateTime's constructor properly handles month overflow.
final from = DateTime(now.year, now.month - monthsFromNow, 1); final from = DateTime(now.year, now.month - monthDelta, 1);
_selectRange(DateTimeRange(start: from, end: now)); return DateTimeRange<DateTime>(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<DateTime> 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<DateTime>(start: from, end: now);
}
final to = DateTime(year, 12, 31, 23, 59, 59);
return DateTimeRange<DateTime>(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<DateTime> range) {
return CustomDateFilter(range.start, range.end);
}
@override
DateTimeRange<DateTime> asDateTimeRange() {
return DateTimeRange<DateTime>(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<int> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.builder( return Padding(
itemBuilder: (context, index) { padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
if (index == 0) { child: SingleChildScrollView(
return ListTile( clipBehavior: Clip.none,
title: Text('pick_exact_date'.tr()), child: RadioGroup(
onTap: () { onChanged: (value) {
onRequestPicker(); switch (value) {
}, case _QuickPickerType.custom:
); onRequestPicker();
} else if (index == 1) { break;
return _monthListTile(context, 1); case _QuickPickerType.last1Month:
} else if (index == 2) { onSelect(RecentMonthRangeFilter(1));
return _monthListTile(context, 3); break;
} else if (index == 3) { case _QuickPickerType.last3Months:
return _monthListTile(context, 9); onSelect(RecentMonthRangeFilter(3));
} else { break;
final now = DateTime.now(); case _QuickPickerType.last9Months:
final years = index - 4; onSelect(RecentMonthRangeFilter(9));
final year = now.year - years; break;
return ListTile( case _QuickPickerType.year:
title: Text("in_year".tr(namedArgs: {"year": year.toString()})), // The combobox triggers the onSelect event on its own so if this is ever selected it can only be on
onTap: () { // the default value set by the constructor
final from = DateTime(year, 1, 1); onSelect(YearFilter(_initialYear));
final to = DateTime(year, 12, 31, 23, 59, 59); break;
_selectRange(DateTimeRange(start: from, end: to)); default:
}, break;
); }
} },
}, groupValue: _selection,
itemCount: 50, 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),
],
),
),
),
); );
} }
} }