fix(mobile): use context search once server features are loaded (#31800)

Co-authored-by: Dawid Krynski <188586034+DawidKrynski@users.noreply.github.com>
Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
(cherry picked from commit be8e42526700d9ec5e72061e33f5039d50e40bae)
This commit is contained in:
DawidKrynski
2026-10-29 20:43:01 +03:00
committed by Codex
parent f7697264ed
commit c8a957a116
2 changed files with 85 additions and 25 deletions
@@ -42,12 +42,16 @@ class SearchPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures)); final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
final textSearchType = useState<TextSearchType>( // Stays null until the user picks a type, so the default follows the server features once they are loaded
serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename, final selectedTextSearchType = useState<TextSearchType?>(null);
); final textSearchType =
final searchHintText = useState<String>( selectedTextSearchType.value ?? (serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename);
serverFeatures.smartSearch ? context.t.sunrise_on_the_beach : context.t.file_name_or_extension, final searchHintText = switch (textSearchType) {
); TextSearchType.context => context.t.sunrise_on_the_beach,
TextSearchType.filename => context.t.file_name_or_extension,
TextSearchType.description => context.t.search_by_description_example,
TextSearchType.ocr => context.t.search_by_ocr_example,
};
final textSearchController = useTextEditingController(); final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>( final filter = useState<SearchFilter>(
SearchFilter( SearchFilter(
@@ -491,14 +495,14 @@ class SearchPage extends HookConsumerWidget {
); );
} }
void handleTextSubmitted(String value) => search(switch (textSearchType.value) { void handleTextSubmitted(String value) => search(switch (textSearchType) {
TextSearchType.context => filter.value.copyWith(filename: '', context: value, description: '', ocr: ''), TextSearchType.context => filter.value.copyWith(filename: '', context: value, description: '', ocr: ''),
TextSearchType.filename => filter.value.copyWith(filename: value, context: '', description: '', ocr: ''), TextSearchType.filename => filter.value.copyWith(filename: value, context: '', description: '', ocr: ''),
TextSearchType.description => filter.value.copyWith(filename: '', context: '', description: value, ocr: ''), TextSearchType.description => filter.value.copyWith(filename: '', context: '', description: value, ocr: ''),
TextSearchType.ocr => filter.value.copyWith(filename: '', context: '', description: '', ocr: value), TextSearchType.ocr => filter.value.copyWith(filename: '', context: '', description: '', ocr: value),
}); });
IconData getSearchPrefixIcon() => switch (textSearchType.value) { IconData getSearchPrefixIcon() => switch (textSearchType) {
TextSearchType.context => Icons.image_search_rounded, TextSearchType.context => Icons.image_search_rounded,
TextSearchType.filename => Icons.abc_rounded, TextSearchType.filename => Icons.abc_rounded,
TextSearchType.description => Icons.text_snippet_outlined, TextSearchType.description => Icons.text_snippet_outlined,
@@ -543,15 +547,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_context, context.t.search_by_context,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null, color: textSearchType == TextSearchType.context ? context.colorScheme.primary : null,
), ),
), ),
selectedColor: context.colorScheme.primary, selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.context, selected: textSearchType == TextSearchType.context,
), ),
onPressed: () { onPressed: () {
textSearchType.value = TextSearchType.context; selectedTextSearchType.value = TextSearchType.context;
searchHintText.value = context.t.sunrise_on_the_beach;
}, },
), ),
), ),
@@ -562,15 +565,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_filter_filename, context.t.search_filter_filename,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.filename ? context.colorScheme.primary : null, color: textSearchType == TextSearchType.filename ? context.colorScheme.primary : null,
), ),
), ),
selectedColor: context.colorScheme.primary, selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.filename, selected: textSearchType == TextSearchType.filename,
), ),
onPressed: () { onPressed: () {
textSearchType.value = TextSearchType.filename; selectedTextSearchType.value = TextSearchType.filename;
searchHintText.value = context.t.file_name_or_extension;
}, },
), ),
MenuItemButton( MenuItemButton(
@@ -580,15 +582,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_description, context.t.search_by_description,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.description ? context.colorScheme.primary : null, color: textSearchType == TextSearchType.description ? context.colorScheme.primary : null,
), ),
), ),
selectedColor: context.colorScheme.primary, selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.description, selected: textSearchType == TextSearchType.description,
), ),
onPressed: () { onPressed: () {
textSearchType.value = TextSearchType.description; selectedTextSearchType.value = TextSearchType.description;
searchHintText.value = context.t.search_by_description_example;
}, },
), ),
FeatureCheck( FeatureCheck(
@@ -600,15 +601,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_ocr, context.t.search_by_ocr,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null, color: textSearchType == TextSearchType.ocr ? context.colorScheme.primary : null,
), ),
), ),
selectedColor: context.colorScheme.primary, selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.ocr, selected: textSearchType == TextSearchType.ocr,
), ),
onPressed: () { onPressed: () {
textSearchType.value = TextSearchType.ocr; selectedTextSearchType.value = TextSearchType.ocr;
searchHintText.value = context.t.search_by_ocr_example;
}, },
), ),
), ),
@@ -631,7 +631,7 @@ class SearchPage extends HookConsumerWidget {
), ),
), ),
child: SearchField( child: SearchField(
hintText: searchHintText.value, hintText: searchHintText,
key: const Key('search_text_field'), key: const Key('search_text_field'),
controller: textSearchController, controller: textSearchController,
contentPadding: filter.value.assetId != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8), contentPadding: filter.value.assetId != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8),
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/pages/search/search.page.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import '../../unit/presentation/presentation_context.dart';
/// Server info whose features arrive after the page is built, like on a slow or late connection
class DelayedServerInfoNotifier extends ServerInfoNotifier {
DelayedServerInfoNotifier(super.serverInfoService);
void enableSmartSearch() => state = state.copyWith(serverFeatures: state.serverFeatures.copyWith(smartSearch: true));
}
void main() {
late PresentationContext context;
setUp(() async => context = await PresentationContext.create());
tearDown(() async => context.dispose());
final contextSearchIcon = find.byIcon(Icons.image_search_rounded);
final filenameSearchIcon = find.byIcon(Icons.abc_rounded);
Future<DelayedServerInfoNotifier> pumpSearchPage(WidgetTester tester) async {
final serverInfo = DelayedServerInfoNotifier(context.service.serverInfo);
await tester.pumpTestWidget(
context,
const SearchPage(),
overrides: [serverInfoProvider.overrideWith((ref) => serverInfo)],
);
return serverInfo;
}
testWidgets('uses context search once smart search is reported by the server', (tester) async {
final serverInfo = await pumpSearchPage(tester);
expect(filenameSearchIcon, findsOneWidget);
serverInfo.enableSmartSearch();
await tester.pumpAndSettle();
expect(contextSearchIcon, findsOneWidget);
expect(filenameSearchIcon, findsNothing);
});
testWidgets('keeps the search type selected by the user', (tester) async {
final serverInfo = await pumpSearchPage(tester);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.text_snippet_outlined));
await tester.pumpAndSettle();
serverInfo.enableSmartSearch();
await tester.pumpAndSettle();
expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget);
expect(contextSearchIcon, findsNothing);
});
}