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.
This commit is contained in:
Thomas Way
2026-04-05 03:34:46 +01:00
parent 960be0c27a
commit 319b468519
9 changed files with 221 additions and 290 deletions
@@ -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);
}
}
@@ -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 ^