Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(mobile): fix text search #14873

Merged
merged 8 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mobile/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ custom_lint:
- lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart
- lib/services/auth.service.dart # on ApiException
- test/services/auth.service_test.dart # on ApiException
# allow import from test
- test/**.dart

dart_code_metrics:
metrics:
Expand Down
4 changes: 2 additions & 2 deletions mobile/lib/models/search/search_filter.model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ class SearchFilter {
AssetType? mediaType,
}) {
return SearchFilter(
context: context,
filename: filename,
context: context ?? this.context,
filename: filename ?? this.filename,
people: people ?? this.people,
location: location ?? this.location,
camera: camera ?? this.camera,
Expand Down
12 changes: 6 additions & 6 deletions mobile/lib/pages/search/search.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -441,19 +441,15 @@ class SearchPage extends HookConsumerWidget {
}

handleTextSubmitted(String value) {
if (value.isEmpty) {
return;
}

if (isContextualSearch.value) {
filter.value = filter.value.copyWith(
filename: null,
johnstef99 marked this conversation as resolved.
Show resolved Hide resolved
filename: '',
context: value,
);
} else {
filter.value = filter.value.copyWith(
filename: value,
context: null,
context: '',
johnstef99 marked this conversation as resolved.
Show resolved Hide resolved
);
}

Expand All @@ -468,6 +464,7 @@ class SearchPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(right: 14.0),
child: IconButton(
key: const Key('contextual_search_button'),
icon: isContextualSearch.value
? const Icon(Icons.abc_rounded)
: const Icon(Icons.image_search_rounded),
Expand Down Expand Up @@ -496,6 +493,7 @@ class SearchPage extends HookConsumerWidget {
),
),
child: TextField(
key: const Key('search_text_field'),
controller: textSearchController,
decoration: InputDecoration(
contentPadding: prefilter != null
Expand Down Expand Up @@ -551,6 +549,7 @@ class SearchPage extends HookConsumerWidget {
child: SizedBox(
height: 50,
child: ListView(
key: const Key('search_filter_chip_list'),
shrinkWrap: true,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
Expand Down Expand Up @@ -580,6 +579,7 @@ class SearchPage extends HookConsumerWidget {
currentFilter: dateRangeCurrentFilterWidget.value,
),
SearchFilterChip(
key: const Key('media_type_chip'),
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.tr(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class FilterBottomSheetScaffold extends StatelessWidget {
),
const SizedBox(width: 8),
ElevatedButton(
key: const Key('search_filter_apply'),
onPressed: () {
onSearch();
context.pop();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class MediaTypePicker extends HookWidget {
shrinkWrap: true,
children: [
RadioListTile(
key: const Key("search_filter_media_type_all"),
title: const Text("search_filter_media_type_all").tr(),
value: AssetType.other,
onChanged: (value) {
Expand All @@ -26,6 +27,7 @@ class MediaTypePicker extends HookWidget {
groupValue: selectedMediaType.value,
),
RadioListTile(
key: const Key("search_filter_media_type_image"),
title: const Text("search_filter_media_type_image").tr(),
value: AssetType.image,
onChanged: (value) {
Expand All @@ -35,6 +37,7 @@ class MediaTypePicker extends HookWidget {
groupValue: selectedMediaType.value,
),
RadioListTile(
key: const Key("search_filter_media_type_video"),
title: const Text("search_filter_media_type_video").tr(),
value: AssetType.video,
onChanged: (value) {
Expand Down
3 changes: 3 additions & 0 deletions mobile/openapi/devtools_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
6 changes: 6 additions & 0 deletions mobile/test/dto.mocks.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';

class MockSmartSearchDto extends Mock implements SmartSearchDto {}

class MockMetadataSearchDto extends Mock implements MetadataSearchDto {}
189 changes: 189 additions & 0 deletions mobile/test/pages/search/search.page_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.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:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';

import '../../dto.mocks.dart';
import '../../service.mocks.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';

void main() {
late List<Override> overrides;
late Isar db;
late MockApiService mockApiService;
late MockSearchApi mockSearchApi;

setUpAll(() async {
TestUtils.init();
db = await TestUtils.initIsar();
Store.init(db);
mockApiService = MockApiService();
mockSearchApi = MockSearchApi();
when(() => mockApiService.searchApi).thenReturn(mockSearchApi);
registerFallbackValue(MockSmartSearchDto());
registerFallbackValue(MockMetadataSearchDto());
overrides = [
paginatedSearchRenderListProvider
.overrideWithValue(AsyncValue.data(RenderList.empty())),
dbProvider.overrideWithValue(db),
apiServiceProvider.overrideWithValue(mockApiService),
];
});

final emptyTextSearch = isA<MetadataSearchDto>()
.having((s) => s.originalFileName, 'originalFileName', null);

testWidgets('contextual search with/without text', (tester) async {
await tester.pumpConsumerWidget(
const SearchPage(),
overrides: overrides,
);

await tester.pumpAndSettle();

expect(
find.byIcon(Icons.abc_rounded),
findsOneWidget,
reason: 'Should have contextual search icon',
);

final searchField = find.byKey(const Key('search_text_field'));
expect(searchField, findsOneWidget);

await tester.enterText(searchField, 'test');
await tester.testTextInput.receiveAction(TextInputAction.search);

var captured = verify(
() => mockSearchApi.searchSmart(captureAny()),
).captured;

expect(
captured.first,
isA<SmartSearchDto>().having((s) => s.query, 'query', 'test'),
);

await tester.enterText(searchField, '');
await tester.testTextInput.receiveAction(TextInputAction.search);

captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
expect(captured.first, emptyTextSearch);
});

testWidgets('not contextual search with/without text', (tester) async {
await tester.pumpConsumerWidget(
const SearchPage(),
overrides: overrides,
);

await tester.pumpAndSettle();

await tester.tap(find.byKey(const Key('contextual_search_button')));

await tester.pumpAndSettle();

expect(
find.byIcon(Icons.image_search_rounded),
findsOneWidget,
reason: 'Should not have contextual search icon',
);

final searchField = find.byKey(const Key('search_text_field'));
expect(searchField, findsOneWidget);

await tester.enterText(searchField, 'test');
await tester.testTextInput.receiveAction(TextInputAction.search);

var captured = verify(
() => mockSearchApi.searchAssets(captureAny()),
).captured;

expect(
captured.first,
isA<MetadataSearchDto>()
.having((s) => s.originalFileName, 'originalFileName', 'test'),
);

await tester.enterText(searchField, '');
await tester.testTextInput.receiveAction(TextInputAction.search);

captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
expect(captured.first, emptyTextSearch);
});

// COME BACK LATER
// testWidgets('contextual search with text combined with media type',
// (tester) async {
// await tester.pumpConsumerWidget(
// const SearchPage(),
// overrides: overrides,
// );

// await tester.pumpAndSettle();

// expect(
// find.byIcon(Icons.abc_rounded),
// findsOneWidget,
// reason: 'Should have contextual search icon',
// );

// final searchField = find.byKey(const Key('search_text_field'));
// expect(searchField, findsOneWidget);

// await tester.enterText(searchField, 'test');
// await tester.testTextInput.receiveAction(TextInputAction.search);

// var captured = verify(
// () => mockSearchApi.searchSmart(captureAny()),
// ).captured;

// expect(
// captured.first,
// isA<SmartSearchDto>().having((s) => s.query, 'query', 'test'),
// );

// await tester.dragUntilVisible(
// find.byKey(const Key('media_type_chip')),
// find.byKey(const Key('search_filter_chip_list')),
// const Offset(-100, 0),
// );
// await tester.pumpAndSettle();

// await tester.tap(find.byKey(const Key('media_type_chip')));
// await tester.pumpAndSettle();

// await tester.tap(find.byKey(const Key('search_filter_media_type_image')));
// await tester.pumpAndSettle();

// await tester.tap(find.byKey(const Key('search_filter_apply')));
// await tester.pumpAndSettle();

// captured = verify(() => mockSearchApi.searchSmart(captureAny())).captured;

// expect(
// captured.first,
// isA<SmartSearchDto>()
// .having((s) => s.query, 'query', 'test')
// .having((s) => s.type, 'type', AssetTypeEnum.IMAGE),
// );

// await tester.enterText(searchField, '');
// await tester.testTextInput.receiveAction(TextInputAction.search);

// captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
// expect(
// captured.first,
// isA<MetadataSearchDto>()
// .having((s) => s.originalFileName, 'originalFileName', null)
// .having((s) => s.type, 'type', AssetTypeEnum.IMAGE),
// );
// });
}
3 changes: 3 additions & 0 deletions mobile/test/service.mocks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:immich_mobile/services/network.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';

class MockApiService extends Mock implements ApiService {}

Expand All @@ -17,3 +18,5 @@ class MockHashService extends Mock implements HashService {}
class MockEntityService extends Mock implements EntityService {}

class MockNetworkService extends Mock implements NetworkService {}

class MockSearchApi extends Mock implements SearchApi {}
Loading