Skip to content

feat(mobile): drift search page #19811

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

Merged
merged 3 commits into from
Jul 17, 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
1 change: 1 addition & 0 deletions mobile/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ custom_lint:
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
- lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database
- lib/domain/services/search.service.dart

# refactor
- lib/models/map/map_marker.model.dart
Expand Down
38 changes: 38 additions & 0 deletions mobile/lib/domain/models/search_result.model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';

class SearchResult {
final List<BaseAsset> assets;
final int? nextPage;

const SearchResult({
required this.assets,
this.nextPage,
});

int get totalAssets => assets.length;

SearchResult copyWith({
List<BaseAsset>? assets,
int? nextPage,
}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
);
}

@override
String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)';

@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;

return listEquals(other.assets, assets) && other.nextPage == nextPage;
}

@override
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}
92 changes: 92 additions & 0 deletions mobile/lib/domain/services/search.service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;

class SearchService {
final _log = Logger("SearchService");
final SearchApiRepository _searchApiRepository;

SearchService(this._searchApiRepository);

Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) async {
try {
return await _searchApiRepository.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
} catch (e) {
_log.warning("Failed to get search suggestions", e);
}
return [];
}

Future<SearchResult?> search(SearchFilter filter, int page) async {
try {
final response = await _searchApiRepository.search(filter, page);

if (response == null || response.assets.items.isEmpty) {
return null;
}

return SearchResult(
assets: response.assets.items.map((e) => e.toDto()).toList(),
nextPage: response.assets.nextPage?.toInt(),
);
} catch (error, stackTrace) {
_log.severe("Failed to search for assets", error, stackTrace);
}
return null;
}
}

extension on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
);
}
}

extension on AssetTypeEnum {
AssetType toAssetType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}
3 changes: 3 additions & 0 deletions mobile/lib/domain/services/timeline.service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class TimelineFactory {

TimelineService place(String place) =>
TimelineService(_timelineRepository.place(place, groupBy));

TimelineService fromAssets(List<BaseAsset> assets) =>
TimelineService(_timelineRepository.fromAssets(assets));
}

class TimelineService {
Expand Down
87 changes: 87 additions & 0 deletions mobile/lib/infrastructure/repositories/search_api.repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'
hide AssetVisibility;
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:openapi/api.dart';

class SearchApiRepository extends ApiRepository {
final SearchApi _api;
const SearchApiRepository(this._api);

Future<SearchResponseDto?> search(SearchFilter filter, int page) {
AssetTypeEnum? type;
if (filter.mediaType.index == AssetType.image.index) {
type = AssetTypeEnum.IMAGE;
} else if (filter.mediaType.index == AssetType.video.index) {
type = AssetTypeEnum.VIDEO;
}

if (filter.context != null && filter.context!.isNotEmpty) {
return _api.searchSmart(
SmartSearchDto(
query: filter.context!,
language: filter.language,
country: filter.location.country,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive
? AssetVisibility.archive
: AssetVisibility.timeline,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
size: 1000,
),
);
}

return _api.searchAssets(
MetadataSearchDto(
originalFileName: filter.filename != null && filter.filename!.isNotEmpty
? filter.filename
: null,
country: filter.location.country,
description:
filter.description != null && filter.description!.isNotEmpty
? filter.description
: null,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive
? AssetVisibility.archive
: AssetVisibility.timeline,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
size: 1000,
),
);
}

Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) =>
_api.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.get();
}

TimelineQuery fromAssets(List<BaseAsset> assets) => (
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
assetSource: (offset, count) =>
Future.value(assets.skip(offset).take(count).toList()),
);

TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) =>
_remoteQueryBuilder(
filter: (row) =>
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/pages/common/tab_shell.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
return AutoTabsRouter(
routes: [
const MainTimelineRoute(),
SearchRoute(),
DriftSearchRoute(),
const DriftAlbumsRoute(),
const DriftLibraryRoute(),
],
Expand Down
10 changes: 0 additions & 10 deletions mobile/lib/presentation/pages/dev/feat_in_development.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,6 @@ final _features = [
icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
),
_Feature(
name: 'Video',
icon: Icons.video_collection_outlined,
onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()),
),
_Feature(
name: 'Recently Taken',
icon: Icons.schedule_outlined,
onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()),
),
_Feature(
name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded,
Expand Down
Loading