Compare commits

...

1 Commits

Author SHA1 Message Date
Thomas Way
319b468519 chore(mobile): use declarative ui in search
The search page makes use of imperative state, which is buggy and
unergonomic - it fights against how flutter wants widgets to be
written. Using declarative state simplifies the code and fixes bugs.
2026-04-05 03:34:46 +01:00
9 changed files with 221 additions and 290 deletions

View File

@@ -16,6 +16,8 @@ class SearchApiRepository extends ApiRepository {
type = AssetTypeEnum.VIDEO;
}
final dateRange = filter.date.asDateTimeRange();
if ((filter.context != null && filter.context!.isNotEmpty) ||
(filter.assetId != null && filter.assetId!.isNotEmpty)) {
return _api.searchSmart(
@@ -28,14 +30,14 @@ class SearchApiRepository extends ApiRepository {
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
takenAfter: dateRange?.start,
takenBefore: dateRange?.end,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
tagIds: filter.tags.map((t) => t.id).toList(),
type: type,
page: page,
size: 100,
@@ -53,14 +55,14 @@ class SearchApiRepository extends ApiRepository {
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
takenAfter: dateRange?.start,
takenBefore: dateRange?.end,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
tagIds: filter.tags.map((t) => t.id).toList(),
type: type,
page: page,
size: 1000,

View File

@@ -0,0 +1,91 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
sealed class DateFilterInputModel {
const DateFilterInputModel();
bool get isEmpty => asDateTimeRange() == null;
DateTimeRange<DateTime>? asDateTimeRange();
String asHumanReadable(BuildContext context) {
final date = asDateTimeRange();
if (date == null) return '';
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()),
},
);
}
}
}
class RecentMonthRangeFilter extends DateFilterInputModel {
final int monthDelta;
const RecentMonthRangeFilter(this.monthDelta);
@override
DateTimeRange<DateTime> 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<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;
const 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 EmptyDateFilter extends DateFilterInputModel {
const EmptyDateFilter();
@override
DateTimeRange<DateTime>? asDateTimeRange() => null;
}
class CustomDateFilter extends DateFilterInputModel {
final DateTime start;
final DateTime end;
const CustomDateFilter._(this.start, this.end);
factory CustomDateFilter.fromRange(DateTimeRange<DateTime> range) {
return CustomDateFilter._(range.start, range.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)));
}
@override
DateTimeRange<DateTime> asDateTimeRange() {
return DateTimeRange<DateTime>(start: start, end: end);
}
}

View File

@@ -2,7 +2,9 @@
import 'dart:convert';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
class SearchLocationFilter {
String? country;
@@ -214,11 +216,11 @@ class SearchFilter {
String? ocr;
String? language;
String? assetId;
List<String>? tagIds;
List<Tag> tags;
Set<PersonDto> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
DateFilterInputModel date;
SearchRatingFilter rating;
SearchDisplayFilters display;
@@ -232,7 +234,7 @@ class SearchFilter {
this.ocr,
this.language,
this.assetId,
this.tagIds,
this.tags = const [],
required this.people,
required this.location,
required this.camera,
@@ -248,15 +250,14 @@ class SearchFilter {
(description == null || (description!.isEmpty)) &&
(assetId == null || (assetId!.isEmpty)) &&
(ocr == null || (ocr!.isEmpty)) &&
(tagIds ?? []).isEmpty &&
tags.isEmpty &&
people.isEmpty &&
location.country == null &&
location.state == null &&
location.city == null &&
camera.make == null &&
camera.model == null &&
date.takenBefore == null &&
date.takenAfter == null &&
date.isEmpty &&
display.isNotInAlbum == false &&
display.isArchive == false &&
display.isFavorite == false &&
@@ -272,10 +273,10 @@ class SearchFilter {
String? ocr,
String? assetId,
Set<PersonDto>? people,
List<String>? tagIds,
List<Tag>? tags,
SearchLocationFilter? location,
SearchCameraFilter? camera,
SearchDateFilter? date,
DateFilterInputModel? date,
SearchDisplayFilters? display,
SearchRatingFilter? rating,
AssetType? mediaType,
@@ -294,13 +295,13 @@ class SearchFilter {
display: display ?? this.display,
rating: rating ?? this.rating,
mediaType: mediaType ?? this.mediaType,
tagIds: tagIds ?? this.tagIds,
tags: tags ?? this.tags,
);
}
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tags: $tags, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
}
@override
@@ -314,7 +315,7 @@ class SearchFilter {
other.ocr == ocr &&
other.assetId == assetId &&
other.people == people &&
other.tagIds == tagIds &&
other.tags == tags &&
other.location == location &&
other.camera == camera &&
other.date == date &&
@@ -332,7 +333,7 @@ class SearchFilter {
ocr.hashCode ^
assetId.hashCode ^
people.hashCode ^
tagIds.hashCode ^
tags.hashCode ^
location.hashCode ^
camera.hashCode ^
date.hashCode ^

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
@@ -111,7 +112,7 @@ class PlaceTile extends StatelessWidget {
people: {},
location: SearchLocationFilter(city: name),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
date: const EmptyDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -40,7 +41,7 @@ class SearchPage extends HookConsumerWidget {
people: prefilter?.people ?? {},
location: prefilter?.location ?? SearchLocationFilter(),
camera: prefilter?.camera ?? SearchCameraFilter(),
date: prefilter?.date ?? SearchDateFilter(),
date: prefilter?.date ?? const EmptyDateFilter(),
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
mediaType: prefilter?.mediaType ?? AssetType.other,
rating: prefilter?.rating ?? SearchRatingFilter(),
@@ -242,15 +243,17 @@ class SearchPage extends HookConsumerWidget {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
final stored = filter.value.date.asDateTimeRange();
final dateRange = stored != null
? DateTimeRange(start: DateUtils.dateOnly(stored.start), end: DateUtils.dateOnly(stored.end))
: DateTimeRange(start: lastDate, end: lastDate);
final date = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
),
initialDateRange: dateRange,
helpText: 'search_filter_date_title'.tr(),
cancelText: 'cancel'.tr(),
confirmText: 'select'.tr(),
@@ -264,7 +267,7 @@ class SearchPage extends HookConsumerWidget {
);
if (date == null) {
filter.value = filter.value.copyWith(date: SearchDateFilter());
filter.value = filter.value.copyWith(date: const EmptyDateFilter());
dateRangeCurrentFilterWidget.value = null;
unawaited(search());
@@ -272,10 +275,7 @@ class SearchPage extends HookConsumerWidget {
}
filter.value = filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
),
date: CustomDateFilter.fromRange(date),
);
// If date range is less than 24 hours, set the end date to the end of the day

View File

@@ -16,6 +16,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -57,26 +58,14 @@ class DriftSearchPage extends HookConsumerWidget {
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
date: const EmptyDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}",
tagIds: [],
),
);
final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final tagCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final userPreferences = ref.watch(userMetadataPreferencesProvider);
search(SearchFilter f) {
@@ -107,14 +96,60 @@ class DriftSearchPage extends HookConsumerWidget {
Future.microtask(() {
textSearchController.clear();
search(preFilter);
if (preFilter.location.city != null) {
locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge);
}
});
return null;
}, [preFilter]);
Widget? chipLabel(String text) => text.isEmpty ? null : Text(text, style: context.textTheme.labelLarge);
Widget? peopleChip() {
final label = filter.value.people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
return chipLabel(label);
}
Widget? locationChip() {
final l = filter.value.location;
final parts = [if (l.country != null) l.country!, if (l.state != null) l.state!, if (l.city != null) l.city!];
return chipLabel(parts.join(', '));
}
Widget? tagChip() {
final label = filter.value.tags.map((t) => t.value).join(', ');
return chipLabel(label);
}
Widget? cameraChip() {
final c = filter.value.camera;
return chipLabel('${c.make ?? ''} ${c.model ?? ''}'.trim());
}
Widget? dateChip() {
final d = filter.value.date;
return d.isEmpty ? null : chipLabel(d.asHumanReadable(context));
}
Widget? mediaTypeChip() {
final mt = filter.value.mediaType;
if (mt == AssetType.other) return null;
return chipLabel(mt == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context));
}
Widget? ratingChip() {
final r = filter.value.rating.rating;
return r == null ? null : chipLabel('rating_count'.t(args: {'count': r}));
}
Widget? displayChip() {
final d = filter.value.display;
final parts = [
if (d.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context),
if (d.isArchive) 'archive'.t(context: context),
if (d.isFavorite) 'favorite'.t(context: context),
];
return chipLabel(parts.join(', '));
}
showPeoplePicker() {
var people = filter.value.people;
@@ -122,17 +157,6 @@ class DriftSearchPage extends HookConsumerWidget {
people = value;
}
handleClear() {
peopleCurrentFilterWidget.value = null;
search(filter.value.copyWith(people: {}));
}
handleApply() {
final label = people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
peopleCurrentFilterWidget.value = label.isNotEmpty ? Text(label, style: context.textTheme.labelLarge) : null;
search(filter.value.copyWith(people: people));
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
@@ -141,8 +165,8 @@ class DriftSearchPage extends HookConsumerWidget {
child: FilterBottomSheetScaffold(
title: 'search_filter_people_title'.t(context: context),
expanded: true,
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(people: people)),
onClear: () => search(filter.value.copyWith(people: {})),
child: PeoplePicker(onSelect: handleOnSelect, filter: filter.value.people),
),
),
@@ -150,22 +174,10 @@ class DriftSearchPage extends HookConsumerWidget {
}
showTagPicker() {
var tagIds = filter.value.tagIds ?? [];
String tagLabel = '';
var tags = filter.value.tags;
handleOnSelect(Iterable<Tag> tags) {
tagIds = tags.map((t) => t.id).toList();
tagLabel = tags.map((t) => t.value).join(', ');
}
handleClear() {
tagCurrentFilterWidget.value = null;
search(filter.value.copyWith(tagIds: []));
}
handleApply() {
tagCurrentFilterWidget.value = tagLabel.isNotEmpty ? Text(tagLabel, style: context.textTheme.labelLarge) : null;
search(filter.value.copyWith(tagIds: tagIds));
handleOnSelect(Iterable<Tag> selected) {
tags = selected.toList();
}
showFilterBottomSheet(
@@ -176,9 +188,9 @@ class DriftSearchPage extends HookConsumerWidget {
child: FilterBottomSheetScaffold(
title: 'search_filter_tags_title'.t(context: context),
expanded: true,
onSearch: handleApply,
onClear: handleClear,
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
onSearch: () => search(filter.value.copyWith(tags: tags)),
onClear: () => search(filter.value.copyWith(tags: [])),
child: TagPicker(onSelect: handleOnSelect, filter: filter.value.tags.map((t) => t.id).toSet()),
),
),
);
@@ -191,31 +203,14 @@ class DriftSearchPage extends HookConsumerWidget {
location = SearchLocationFilter(country: value['country'], city: value['city'], state: value['state']);
}
handleClear() {
locationCurrentFilterWidget.value = null;
search(filter.value.copyWith(location: SearchLocationFilter()));
}
handleApply() {
final locationText = [
if (location.country != null) location.country!,
if (location.state != null) location.state!,
if (location.city != null) location.city!,
];
locationCurrentFilterWidget.value = locationText.isNotEmpty
? Text(locationText.join(', '), style: context.textTheme.labelLarge)
: null;
search(filter.value.copyWith(location: location));
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(location: location)),
onClear: () => search(filter.value.copyWith(location: SearchLocationFilter())),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
@@ -237,28 +232,14 @@ class DriftSearchPage extends HookConsumerWidget {
camera = SearchCameraFilter(make: value['make'], model: value['model']);
}
handleClear() {
cameraCurrentFilterWidget.value = null;
search(filter.value.copyWith(camera: SearchCameraFilter()));
}
handleApply() {
final make = camera.make ?? '';
final model = camera.model ?? '';
cameraCurrentFilterWidget.value = (make.isNotEmpty || model.isNotEmpty)
? Text('$make $model', style: context.textTheme.labelLarge)
: null;
search(filter.value.copyWith(camera: camera));
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(camera: camera)),
onClear: () => search(filter.value.copyWith(camera: SearchCameraFilter())),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CameraPicker(onSelect: handleOnSelect, filter: filter.value.camera),
@@ -268,42 +249,17 @@ class DriftSearchPage extends HookConsumerWidget {
}
datePicked(DateFilterInputModel? selectedDate) {
dateInputFilter.value = selectedDate;
if (selectedDate == null) {
dateRangeCurrentFilterWidget.value = null;
search(filter.value.copyWith(date: SearchDateFilter()));
return;
}
final date = selectedDate.asDateTimeRange();
dateRangeCurrentFilterWidget.value = Text(
selectedDate.asHumanReadable(context),
style: context.textTheme.labelLarge,
);
search(
filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
),
),
);
search(filter.value.copyWith(date: selectedDate ?? const EmptyDateFilter()));
}
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
var dateRange = DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
);
// datePicked() may increase the date, this will make the date picker fail an assertion
// Fixup the end date to be at most now.
if (dateRange.end.isAfter(lastDate)) {
dateRange = DateTimeRange(start: dateRange.start, end: lastDate);
}
final stored = filter.value.date.asDateTimeRange();
final dateRange = stored != null
? DateTimeRange(start: DateUtils.dateOnly(stored.start), end: DateUtils.dateOnly(stored.end))
: DateTimeRange(start: lastDate, end: lastDate);
final date = await showDateRangePicker(
context: context,
@@ -338,7 +294,7 @@ class DriftSearchPage extends HookConsumerWidget {
expanded: true,
onClear: () => datePicked(null),
child: QuickDatePicker(
currentInput: dateInputFilter.value,
currentInput: filter.value.date,
onRequestPicker: () {
context.pop();
showDatePicker();
@@ -360,27 +316,12 @@ class DriftSearchPage extends HookConsumerWidget {
mediaType = assetType;
}
handleClear() {
mediaTypeCurrentFilterWidget.value = null;
search(filter.value.copyWith(mediaType: AssetType.other));
}
handleApply() {
mediaTypeCurrentFilterWidget.value = mediaType != AssetType.other
? Text(
mediaType == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context),
style: context.textTheme.labelLarge,
)
: null;
search(filter.value.copyWith(mediaType: mediaType));
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_media_type_title'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(mediaType: mediaType)),
onClear: () => search(filter.value.copyWith(mediaType: AssetType.other)),
child: MediaTypePicker(onSelect: handleOnSelected, filter: filter.value.mediaType),
),
);
@@ -394,25 +335,13 @@ class DriftSearchPage extends HookConsumerWidget {
rating = value;
}
handleClear() {
ratingCurrentFilterWidget.value = null;
search(filter.value.copyWith(rating: SearchRatingFilter(rating: null)));
}
handleApply() {
ratingCurrentFilterWidget.value = rating.rating != null
? Text('rating_count'.t(args: {'count': rating.rating!}), style: context.textTheme.labelLarge)
: null;
search(filter.value.copyWith(rating: rating));
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FilterBottomSheetScaffold(
title: 'rating'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(rating: rating)),
onClear: () => search(filter.value.copyWith(rating: SearchRatingFilter(rating: null))),
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
),
);
@@ -430,33 +359,16 @@ class DriftSearchPage extends HookConsumerWidget {
);
}
handleClear() {
displayOptionCurrentFilterWidget.value = null;
search(
filter.value.copyWith(
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
),
);
}
handleApply() {
final filterText = [
if (display.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context),
if (display.isArchive) 'archive'.t(context: context),
if (display.isFavorite) 'favorite'.t(context: context),
];
displayOptionCurrentFilterWidget.value = filterText.isNotEmpty
? Text(filterText.join(', '), style: context.textTheme.labelLarge)
: null;
search(filter.value.copyWith(display: display));
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'display_options'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(display: display)),
onClear: () => search(
filter.value.copyWith(
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
),
),
child: DisplayOptionPicker(onSelect: handleOnSelect, filter: filter.value.display),
),
);
@@ -631,52 +543,52 @@ class DriftSearchPage extends HookConsumerWidget {
icon: Icons.people_alt_outlined,
onTap: showPeoplePicker,
label: 'people'.t(context: context),
currentFilter: peopleCurrentFilterWidget.value,
currentFilter: peopleChip(),
),
SearchFilterChip(
icon: Icons.location_on_outlined,
onTap: showLocationPicker,
label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value,
currentFilter: locationChip(),
),
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
SearchFilterChip(
icon: Icons.sell_outlined,
onTap: showTagPicker,
label: 'tags'.t(context: context),
currentFilter: tagCurrentFilterWidget.value,
currentFilter: tagChip(),
),
SearchFilterChip(
icon: Icons.camera_alt_outlined,
onTap: showCameraPicker,
label: 'camera'.t(context: context),
currentFilter: cameraCurrentFilterWidget.value,
currentFilter: cameraChip(),
),
SearchFilterChip(
icon: Icons.date_range_outlined,
onTap: showQuickDatePicker,
label: 'search_filter_date'.t(context: context),
currentFilter: dateRangeCurrentFilterWidget.value,
currentFilter: dateChip(),
),
SearchFilterChip(
key: const Key('media_type_chip'),
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
currentFilter: mediaTypeChip(),
),
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
currentFilter: ratingChip(),
),
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label: 'search_filter_display_options'.t(context: context),
currentFilter: displayOptionCurrentFilterWidget.value,
currentFilter: displayChip(),
),
],
),

View File

@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -34,7 +35,7 @@ class SimilarPhotosActionButton extends ConsumerWidget {
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
date: const EmptyDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,

View File

@@ -2,85 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
sealed class DateFilterInputModel {
DateTimeRange<DateTime> asDateTimeRange();
String asHumanReadable(BuildContext context) {
// General 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()),
},
);
}
}
}
class RecentMonthRangeFilter extends DateFilterInputModel {
final int monthDelta;
RecentMonthRangeFilter(this.monthDelta);
@override
DateTimeRange<DateTime> 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<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);
}
}
import 'package:immich_mobile/models/search/date_filter.model.dart';
enum _QuickPickerType { last1Month, last3Months, last9Months, year, custom }
@@ -102,7 +24,7 @@ class QuickDatePicker extends HookWidget {
});
static int _initialYearFromModel(DateFilterInputModel? model) {
return model?.asDateTimeRange().start.year ?? DateTime.now().year;
return model?.asDateTimeRange()?.start.year ?? DateTime.now().year;
}
static _QuickPickerType? _selectionFromModel(DateFilterInputModel? model) {
@@ -149,7 +71,7 @@ class QuickDatePicker extends HookWidget {
// Even if it's already toggled it should always open the full date picker, RadioListTiles don't do that by default
// so we wrap it in a InkWell
Widget _exactPicker(BuildContext context) {
final hasPreviousInput = currentInput != null && currentInput is CustomDateFilter;
final hasPreviousInput = currentInput is CustomDateFilter;
return InkWell(
onTap: onRequestPicker,
@@ -182,9 +104,9 @@ class QuickDatePicker extends HookWidget {
if (value == null) return;
final _ = switch (value) {
_QuickPickerType.custom => onRequestPicker(),
_QuickPickerType.last1Month => onSelect(RecentMonthRangeFilter(1)),
_QuickPickerType.last3Months => onSelect(RecentMonthRangeFilter(3)),
_QuickPickerType.last9Months => onSelect(RecentMonthRangeFilter(9)),
_QuickPickerType.last1Month => onSelect(const RecentMonthRangeFilter(1)),
_QuickPickerType.last3Months => onSelect(const RecentMonthRangeFilter(3)),
_QuickPickerType.last9Months => onSelect(const RecentMonthRangeFilter(9)),
// When a year is selected the combobox triggers onSelect() on its own.
// Here we handle the radio button being selected which can only ever be the initial year
_QuickPickerType.year => onSelect(YearFilter(_initialYear)),

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
@@ -53,7 +54,7 @@ class ExploreGrid extends StatelessWidget {
people: {},
location: SearchLocationFilter(city: content.label),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
date: const EmptyDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,