feat: sort smart search

Smart search currently returns a list of assets by their score. It would
be nice if we could instead filter assets, and then list them by date.
This is the default behaviour of other platforms.
This commit is contained in:
Thomas Way
2026-02-24 10:28:56 +00:00
parent 1d25267f22
commit d416e225e6
17 changed files with 705 additions and 21 deletions
@@ -37,6 +37,7 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
order: filter.order,
page: page,
size: 100,
),
@@ -62,6 +63,7 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
order: filter.order ?? AssetOrder.desc,
page: page,
size: 1000,
),
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:openapi/api.dart' show AssetOrder;
class SearchLocationFilter {
String? country;
@@ -221,6 +222,7 @@ class SearchFilter {
SearchDateFilter date;
SearchRatingFilter rating;
SearchDisplayFilters display;
AssetOrder? order;
// Enum
AssetType mediaType;
@@ -233,6 +235,7 @@ class SearchFilter {
this.language,
this.assetId,
this.tagIds,
this.order,
required this.people,
required this.location,
required this.camera,
@@ -279,6 +282,7 @@ class SearchFilter {
SearchDisplayFilters? display,
SearchRatingFilter? rating,
AssetType? mediaType,
AssetOrder? Function()? order,
}) {
return SearchFilter(
context: context ?? this.context,
@@ -295,12 +299,13 @@ class SearchFilter {
rating: rating ?? this.rating,
mediaType: mediaType ?? this.mediaType,
tagIds: tagIds ?? this.tagIds,
order: order != null ? order() : this.order,
);
}
@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, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId, order: $order)';
}
@override
@@ -320,7 +325,8 @@ class SearchFilter {
other.date == date &&
other.display == display &&
other.rating == rating &&
other.mediaType == mediaType;
other.mediaType == mediaType &&
other.order == order;
}
@override
@@ -338,6 +344,7 @@ class SearchFilter {
date.hashCode ^
display.hashCode ^
rating.hashCode ^
mediaType.hashCode;
mediaType.hashCode ^
order.hashCode;
}
}
+56 -3
View File
@@ -11,6 +11,7 @@ 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/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@@ -23,6 +24,8 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
import 'package:immich_mobile/widgets/search/search_filter/sort_order_picker.dart';
import 'package:openapi/api.dart' show AssetOrder;
@RoutePage()
class SearchPage extends HookConsumerWidget {
@@ -56,6 +59,7 @@ class SearchPage extends HookConsumerWidget {
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final sortOrderCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
@@ -78,6 +82,8 @@ class SearchPage extends HookConsumerWidget {
}
isSearching.value = true;
ref.read(searchGroupByProvider.notifier).state =
filter.value.order != null ? GroupAssetsBy.day : GroupAssetsBy.none;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
@@ -387,6 +393,37 @@ class SearchPage extends HookConsumerWidget {
);
}
// SORT ORDER
showSortOrderPicker() {
handleOnSelect(AssetOrder? value) {
filter.value = filter.value.copyWith(order: () => value);
if (value == null) {
sortOrderCurrentFilterWidget.value = null;
} else if (value == AssetOrder.desc) {
sortOrderCurrentFilterWidget.value = Text('newest_first'.tr(), style: context.textTheme.labelLarge);
} else {
sortOrderCurrentFilterWidget.value = Text('oldest_first'.tr(), style: context.textTheme.labelLarge);
}
}
handleClear() {
filter.value = filter.value.copyWith(order: () => null);
sortOrderCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_sort_order_title'.tr(),
onSearch: search,
onClear: handleClear,
child: SortOrderPicker(onSelect: handleOnSelect, order: filter.value.order),
),
);
}
handleTextSubmitted(String value) {
switch (textSearchType.value) {
case TextSearchType.context:
@@ -594,6 +631,12 @@ class SearchPage extends HookConsumerWidget {
label: 'search_filter_display_options'.tr(),
currentFilter: displayOptionCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.sort_outlined,
onTap: showSortOrderPicker,
label: 'search_filter_sort_order'.tr(),
currentFilter: sortOrderCurrentFilterWidget.value,
),
],
),
),
@@ -601,7 +644,11 @@ class SearchPage extends HookConsumerWidget {
if (isSearching.value)
const Expanded(child: Center(child: CircularProgressIndicator()))
else
SearchResultGrid(onScrollEnd: loadMoreSearchResult, isSearching: isSearching.value),
SearchResultGrid(
onScrollEnd: loadMoreSearchResult,
isSearching: isSearching.value,
dragScrollLabelEnabled: filter.value.order != null,
),
],
),
);
@@ -611,8 +658,14 @@ class SearchPage extends HookConsumerWidget {
class SearchResultGrid extends StatelessWidget {
final VoidCallback onScrollEnd;
final bool isSearching;
final bool dragScrollLabelEnabled;
const SearchResultGrid({super.key, required this.onScrollEnd, this.isSearching = false});
const SearchResultGrid({
super.key,
required this.onScrollEnd,
this.isSearching = false,
this.dragScrollLabelEnabled = false,
});
@override
Widget build(BuildContext context) {
@@ -640,7 +693,7 @@ class SearchResultGrid extends StatelessWidget {
editEnabled: true,
favoriteEnabled: true,
stackEnabled: false,
dragScrollLabelEnabled: false,
dragScrollLabelEnabled: dragScrollLabelEnabled,
emptyIndicator: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: !isSearching ? const SearchEmptyContent() : const SizedBox.shrink(),
@@ -8,6 +8,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'paginated_search.provider.g.dart';
final searchGroupByProvider = StateProvider<GroupAssetsBy>((ref) => GroupAssetsBy.none);
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
@@ -41,6 +43,7 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
@riverpod
Future<RenderList> paginatedSearchRenderList(Ref ref) {
final result = ref.watch(paginatedSearchProvider);
final groupBy = ref.watch(searchGroupByProvider);
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.getTimelineFromAssets(result.assets, GroupAssetsBy.none);
return timelineService.getTimelineFromAssets(result.assets, groupBy);
}
@@ -7,7 +7,7 @@ part of 'paginated_search.provider.dart';
// **************************************************************************
String _$paginatedSearchRenderListHash() =>
r'22d715ff7864e5a946be38322ce7813616f899c2';
r'bb1ea9153b2a186778420426f1fb1add6d6a9140';
/// See also [paginatedSearchRenderList].
@ProviderFor(paginatedSearchRenderList)
@@ -0,0 +1,42 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:openapi/api.dart' show AssetOrder;
enum _SortOption { bestMatch, newest, oldest }
class SortOrderPicker extends HookWidget {
const SortOrderPicker({super.key, required this.onSelect, this.order});
final Function(AssetOrder?) onSelect;
final AssetOrder? order;
@override
Widget build(BuildContext context) {
final selected = useState<_SortOption>(switch (order) {
AssetOrder.desc => _SortOption.newest,
AssetOrder.asc => _SortOption.oldest,
_ => _SortOption.bestMatch,
});
return RadioGroup<_SortOption>(
onChanged: (value) {
if (value == null) return;
selected.value = value;
onSelect(switch (value) {
_SortOption.bestMatch => null,
_SortOption.newest => AssetOrder.desc,
_SortOption.oldest => AssetOrder.asc,
});
},
groupValue: selected.value,
child: Column(
children: [
RadioListTile(title: const Text('best_match').tr(), value: _SortOption.bestMatch),
RadioListTile(title: const Text('newest_first').tr(), value: _SortOption.newest),
RadioListTile(title: const Text('oldest_first').tr(), value: _SortOption.oldest),
],
),
);
}
}
+19 -1
View File
@@ -30,6 +30,7 @@ class SmartSearchDto {
this.make,
this.model,
this.ocr,
this.order,
this.page,
this.personIds = const [],
this.query,
@@ -167,6 +168,15 @@ class SmartSearchDto {
///
String? ocr;
/// Sort order by date. If not provided, results are sorted by relevance.
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AssetOrder? order;
/// Page number
///
/// Minimum value: 1
@@ -338,6 +348,7 @@ class SmartSearchDto {
other.make == make &&
other.model == model &&
other.ocr == ocr &&
other.order == order &&
other.page == page &&
_deepEquality.equals(other.personIds, personIds) &&
other.query == query &&
@@ -377,6 +388,7 @@ class SmartSearchDto {
(make == null ? 0 : make!.hashCode) +
(model == null ? 0 : model!.hashCode) +
(ocr == null ? 0 : ocr!.hashCode) +
(order == null ? 0 : order!.hashCode) +
(page == null ? 0 : page!.hashCode) +
(personIds.hashCode) +
(query == null ? 0 : query!.hashCode) +
@@ -397,7 +409,7 @@ class SmartSearchDto {
(withExif == null ? 0 : withExif!.hashCode);
@override
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, order=$order, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -482,6 +494,11 @@ class SmartSearchDto {
} else {
// json[r'ocr'] = null;
}
if (this.order != null) {
json[r'order'] = this.order;
} else {
// json[r'order'] = null;
}
if (this.page != null) {
json[r'page'] = this.page;
} else {
@@ -599,6 +616,7 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'),
order: AssetOrder.fromJson(json[r'order']),
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)