1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-28 09:33:27 +02:00

feat(mobile): search enhancement (#8392)

This commit is contained in:
Alex 2024-04-01 09:45:11 -05:00 committed by GitHub
parent 861b72ef04
commit 27be813011
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 4018 additions and 2753 deletions

View File

@ -17,6 +17,9 @@ PODS:
- fluttertoast (0.0.2):
- Flutter
- Toast
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- geolocator_apple (1.2.0):
- Flutter
- image_picker_ios (0.0.1):
@ -36,7 +39,7 @@ PODS:
- FlutterMacOS
- path_provider_ios (0.0.1):
- Flutter
- permission_handler_apple (9.3.0):
- permission_handler_apple (9.1.1):
- Flutter
- photo_manager (2.0.0):
- Flutter
@ -50,7 +53,7 @@ PODS:
- FlutterMacOS
- sqflite (0.0.3):
- Flutter
- FlutterMacOS
- FMDB (>= 2.7.5)
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
@ -81,13 +84,14 @@ DEPENDENCIES:
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- FMDB
- MapLibre
- ReachabilitySwift
- SAMKeychain
@ -135,7 +139,7 @@ EXTERNAL SOURCES:
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/darwin"
:path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
@ -151,23 +155,24 @@ SPEC CHECKSUMS:
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
geolocator_apple: 9157311f654584b9bb72686c55fc02a97b73f461
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579
@ -175,4 +180,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.12.1
COCOAPODS: 1.15.2

View File

@ -42,7 +42,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.assetsPerRow,
this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.margin = 2.0,
this.selectionActive = false,
this.preselectedAssets,
this.canDeselect = true,

View File

@ -1,15 +1,60 @@
/// A wrapper for [CuratedLocationsResponseDto] objects
/// and [CuratedObjectsResponseDto] to be displayed in
/// a view
class CuratedContent {
/// The label to show associated with this curated object
final String label;
/// The id to lookup the asset from the server
final String id;
CuratedContent({
required this.id,
required this.label,
});
}
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
/// A wrapper for [CuratedLocationsResponseDto] objects
/// and [CuratedObjectsResponseDto] to be displayed in
/// a view
class CuratedContent {
/// The label to show associated with this curated object
final String label;
/// The id to lookup the asset from the server
final String id;
CuratedContent({
required this.label,
required this.id,
});
CuratedContent copyWith({
String? label,
String? id,
}) {
return CuratedContent(
label: label ?? this.label,
id: id ?? this.id,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'label': label,
'id': id,
};
}
factory CuratedContent.fromMap(Map<String, dynamic> map) {
return CuratedContent(
label: map['label'] as String,
id: map['id'] as String,
);
}
String toJson() => json.encode(toMap());
factory CuratedContent.fromJson(String source) =>
CuratedContent.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'CuratedContent(label: $label, id: $id)';
@override
bool operator ==(covariant CuratedContent other) {
if (identical(this, other)) return true;
return other.label == label && other.id == id;
}
@override
int get hashCode => label.hashCode ^ id.hashCode;
}

View File

@ -0,0 +1,310 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
class SearchLocationFilter {
String? country;
String? state;
String? city;
SearchLocationFilter({
this.country,
this.state,
this.city,
});
SearchLocationFilter copyWith({
String? country,
String? state,
String? city,
}) {
return SearchLocationFilter(
country: country ?? this.country,
state: state ?? this.state,
city: city ?? this.city,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'country': country,
'state': state,
'city': city,
};
}
factory SearchLocationFilter.fromMap(Map<String, dynamic> map) {
return SearchLocationFilter(
country: map['country'] != null ? map['country'] as String : null,
state: map['state'] != null ? map['state'] as String : null,
city: map['city'] != null ? map['city'] as String : null,
);
}
String toJson() => json.encode(toMap());
factory SearchLocationFilter.fromJson(String source) =>
SearchLocationFilter.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() =>
'SearchLocationFilter(country: $country, state: $state, city: $city)';
@override
bool operator ==(covariant SearchLocationFilter other) {
if (identical(this, other)) return true;
return other.country == country &&
other.state == state &&
other.city == city;
}
@override
int get hashCode => country.hashCode ^ state.hashCode ^ city.hashCode;
}
class SearchCameraFilter {
String? make;
String? model;
SearchCameraFilter({
this.make,
this.model,
});
SearchCameraFilter copyWith({
String? make,
String? model,
}) {
return SearchCameraFilter(
make: make ?? this.make,
model: model ?? this.model,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'make': make,
'model': model,
};
}
factory SearchCameraFilter.fromMap(Map<String, dynamic> map) {
return SearchCameraFilter(
make: map['make'] != null ? map['make'] as String : null,
model: map['model'] != null ? map['model'] as String : null,
);
}
String toJson() => json.encode(toMap());
factory SearchCameraFilter.fromJson(String source) =>
SearchCameraFilter.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'SearchCameraFilter(make: $make, model: $model)';
@override
bool operator ==(covariant SearchCameraFilter other) {
if (identical(this, other)) return true;
return other.make == make && other.model == model;
}
@override
int get hashCode => make.hashCode ^ model.hashCode;
}
class SearchDateFilter {
DateTime? takenBefore;
DateTime? takenAfter;
SearchDateFilter({
this.takenBefore,
this.takenAfter,
});
SearchDateFilter copyWith({
DateTime? takenBefore,
DateTime? takenAfter,
}) {
return SearchDateFilter(
takenBefore: takenBefore ?? this.takenBefore,
takenAfter: takenAfter ?? this.takenAfter,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'takenBefore': takenBefore?.millisecondsSinceEpoch,
'takenAfter': takenAfter?.millisecondsSinceEpoch,
};
}
factory SearchDateFilter.fromMap(Map<String, dynamic> map) {
return SearchDateFilter(
takenBefore: map['takenBefore'] != null
? DateTime.fromMillisecondsSinceEpoch(map['takenBefore'] as int)
: null,
takenAfter: map['takenAfter'] != null
? DateTime.fromMillisecondsSinceEpoch(map['takenAfter'] as int)
: null,
);
}
String toJson() => json.encode(toMap());
factory SearchDateFilter.fromJson(String source) =>
SearchDateFilter.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() =>
'SearchDateFilter(takenBefore: $takenBefore, takenAfter: $takenAfter)';
@override
bool operator ==(covariant SearchDateFilter other) {
if (identical(this, other)) return true;
return other.takenBefore == takenBefore && other.takenAfter == takenAfter;
}
@override
int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode;
}
class SearchDisplayFilters {
bool isNotInAlbum = false;
bool isArchive = false;
bool isFavorite = false;
SearchDisplayFilters({
required this.isNotInAlbum,
required this.isArchive,
required this.isFavorite,
});
SearchDisplayFilters copyWith({
bool? isNotInAlbum,
bool? isArchive,
bool? isFavorite,
}) {
return SearchDisplayFilters(
isNotInAlbum: isNotInAlbum ?? this.isNotInAlbum,
isArchive: isArchive ?? this.isArchive,
isFavorite: isFavorite ?? this.isFavorite,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'isNotInAlbum': isNotInAlbum,
'isArchive': isArchive,
'isFavorite': isFavorite,
};
}
factory SearchDisplayFilters.fromMap(Map<String, dynamic> map) {
return SearchDisplayFilters(
isNotInAlbum: map['isNotInAlbum'] as bool,
isArchive: map['isArchive'] as bool,
isFavorite: map['isFavorite'] as bool,
);
}
String toJson() => json.encode(toMap());
factory SearchDisplayFilters.fromJson(String source) =>
SearchDisplayFilters.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() =>
'SearchDisplayFilters(isNotInAlbum: $isNotInAlbum, isArchive: $isArchive, isFavorite: $isFavorite)';
@override
bool operator ==(covariant SearchDisplayFilters other) {
if (identical(this, other)) return true;
return other.isNotInAlbum == isNotInAlbum &&
other.isArchive == isArchive &&
other.isFavorite == isFavorite;
}
@override
int get hashCode =>
isNotInAlbum.hashCode ^ isArchive.hashCode ^ isFavorite.hashCode;
}
class SearchFilter {
String? context;
String? filename;
Set<PersonResponseDto> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
SearchDisplayFilters display;
// Enum
AssetType mediaType;
SearchFilter({
this.context,
this.filename,
required this.people,
required this.location,
required this.camera,
required this.date,
required this.display,
required this.mediaType,
});
SearchFilter copyWith({
String? context,
String? filename,
Set<PersonResponseDto>? people,
SearchLocationFilter? location,
SearchCameraFilter? camera,
SearchDateFilter? date,
SearchDisplayFilters? display,
AssetType? mediaType,
}) {
return SearchFilter(
context: context ?? this.context,
filename: filename ?? this.filename,
people: people ?? this.people,
location: location ?? this.location,
camera: camera ?? this.camera,
date: date ?? this.date,
display: display ?? this.display,
mediaType: mediaType ?? this.mediaType,
);
}
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
}
@override
bool operator ==(covariant SearchFilter other) {
if (identical(this, other)) return true;
return other.context == context &&
other.filename == filename &&
other.people == people &&
other.location == location &&
other.camera == camera &&
other.date == date &&
other.display == display &&
other.mediaType == mediaType;
}
@override
int get hashCode {
return context.hashCode ^
filename.hashCode ^
people.hashCode ^
location.hashCode ^
camera.hashCode ^
date.hashCode ^
display.hashCode ^
mediaType.hashCode;
}
}

View File

@ -0,0 +1,62 @@
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'paginated_search.provider.g.dart';
@riverpod
class PaginatedSearch extends _$PaginatedSearch {
Future<List<Asset>?> _search(SearchFilter filter, int page) async {
final service = ref.read(searchServiceProvider);
final result = await service.search(filter, page);
return result;
}
@override
Future<List<Asset>> build() async {
return [];
}
Future<List<Asset>> getNextPage(SearchFilter filter, int nextPage) async {
state = const AsyncValue.loading();
final newState = await AsyncValue.guard(() async {
final assets = await _search(filter, nextPage);
if (assets != null) {
return [...?state.value, ...assets];
}
});
state = newState.valueOrNull == null
? const AsyncValue.data([])
: AsyncValue.data(newState.value!);
return newState.valueOrNull ?? [];
}
clear() {
state = const AsyncValue.data([]);
}
}
@riverpod
AsyncValue<RenderList> paginatedSearchRenderList(
PaginatedSearchRenderListRef ref,
) {
final assets = ref.watch(paginatedSearchProvider).value;
if (assets != null) {
return ref.watch(
renderListProviderWithGrouping(
(assets, GroupAssetsBy.none),
),
);
} else {
return const AsyncValue.loading();
}
}

Binary file not shown.

View File

@ -1,51 +1,49 @@
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/services/person.service.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'people.provider.g.dart';
@riverpod
Future<List<CuratedContent>> getCuratedPeople(
GetCuratedPeopleRef ref,
) async {
final PersonService personService = ref.read(personServiceProvider);
final curatedPeople = await personService.getCuratedPeople();
return curatedPeople
.map((p) => CuratedContent(id: p.id, label: p.name))
.toList();
}
@riverpod
Future<RenderList> personAssets(PersonAssetsRef ref, String personId) async {
final PersonService personService = ref.read(personServiceProvider);
final assets = await personService.getPersonAssets(personId);
if (assets == null) {
return RenderList.empty();
}
final settings = ref.read(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
return await RenderList.fromAssets(assets, groupBy);
}
@riverpod
Future<bool> updatePersonName(
UpdatePersonNameRef ref,
String personId,
String updatedName,
) async {
final PersonService personService = ref.read(personServiceProvider);
final person = await personService.updateName(personId, updatedName);
if (person != null && person.name == updatedName) {
ref.invalidate(getCuratedPeopleProvider);
return true;
}
return false;
}
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/search/services/person.service.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:openapi/api.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'people.provider.g.dart';
@riverpod
Future<List<PersonResponseDto>> getAllPeople(
GetAllPeopleRef ref,
) async {
final PersonService personService = ref.read(personServiceProvider);
final people = await personService.getAllPeople();
return people;
}
@riverpod
Future<RenderList> personAssets(PersonAssetsRef ref, String personId) async {
final PersonService personService = ref.read(personServiceProvider);
final assets = await personService.getPersonAssets(personId);
if (assets == null) {
return RenderList.empty();
}
final settings = ref.read(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
return await RenderList.fromAssets(assets, groupBy);
}
@riverpod
Future<bool> updatePersonName(
UpdatePersonNameRef ref,
String personId,
String updatedName,
) async {
final PersonService personService = ref.read(personServiceProvider);
final person = await personService.updateName(personId, updatedName);
if (person != null && person.name == updatedName) {
ref.invalidate(getAllPeopleProvider);
return true;
}
return false;
}

View File

@ -0,0 +1,27 @@
import 'package:immich_mobile/modules/search/services/search.service.dart';
import 'package:openapi/api.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_filter.provider.g.dart';
@riverpod
Future<List<String>> getSearchSuggestions(
GetSearchSuggestionsRef ref,
SearchSuggestionType type, {
String? locationCountry,
String? locationState,
String? make,
String? model,
}) async {
final SearchService service = ref.read(searchServiceProvider);
final suggestions = await service.getSearchSuggestions(
type,
country: locationCountry,
state: locationState,
make: make,
model: model,
);
return suggestions ?? [];
}

Binary file not shown.

View File

@ -1,67 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/search/models/search_result_page_state.model.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
SearchResultPageNotifier(this._searchService)
: super(
SearchResultPageState(
searchResult: [],
isError: false,
isLoading: true,
isSuccess: false,
isSmart: false,
),
);
final SearchService _searchService;
Future<void> search(String searchTerm, {bool smartSearch = true}) async {
state = state.copyWith(
searchResult: [],
isError: false,
isLoading: true,
isSuccess: false,
);
List<Asset>? assets =
await _searchService.searchAsset(searchTerm, smartSearch: smartSearch);
if (assets != null) {
state = state.copyWith(
searchResult: assets,
isError: false,
isLoading: false,
isSuccess: true,
isSmart: smartSearch,
);
} else {
state = state.copyWith(
searchResult: [],
isError: true,
isLoading: false,
isSuccess: false,
isSmart: smartSearch,
);
}
}
}
final searchResultPageProvider =
StateNotifierProvider<SearchResultPageNotifier, SearchResultPageState>(
(ref) {
return SearchResultPageNotifier(ref.watch(searchServiceProvider));
});
final searchRenderListProvider = Provider((ref) {
final result = ref.watch(searchResultPageProvider);
return ref.watch(
renderListProviderWithGrouping(
(result.searchResult, result.isSmart ? GroupAssetsBy.none : null),
),
);
});

View File

@ -20,7 +20,7 @@ class PersonService {
PersonService(this._apiService, this._db);
Future<List<PersonResponseDto>> getCuratedPeople() async {
Future<List<PersonResponseDto>> getAllPeople() async {
try {
final peopleResponseDto = await _apiService.personApi.getAllPeople();
return peopleResponseDto?.people ?? [];

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
@ -29,25 +30,92 @@ class SearchService {
}
}
Future<List<Asset>?> searchAsset(
String searchTerm, {
bool smartSearch = true,
Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) async {
// TODO search in local DB: 1. when offline, 2. to find local assets
try {
final SearchResponseDto? results = await _apiService.searchApi.search(
query: searchTerm,
smart: smartSearch,
return await _apiService.searchApi.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
if (results == null) {
} catch (e) {
debugPrint("[ERROR] [getSearchSuggestions] ${e.toString()}");
return [];
}
}
Future<List<Asset>?> search(SearchFilter filter, int page) async {
try {
SearchResponseDto? response;
AssetTypeEnum? type;
if (filter.mediaType == AssetType.image) {
type = AssetTypeEnum.IMAGE;
} else if (filter.mediaType == AssetType.video) {
type = AssetTypeEnum.VIDEO;
}
if (filter.context != null && filter.context!.isNotEmpty) {
response = await _apiService.searchApi.searchSmart(
SmartSearchDto(
query: filter.context!,
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,
isArchived: filter.display.isArchive,
isFavorite: filter.display.isFavorite,
isNotInAlbum: filter.display.isNotInAlbum,
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
size: 1000,
),
);
} else {
response = await _apiService.searchApi.searchMetadata(
MetadataSearchDto(
originalFileName:
filter.filename != null && filter.filename!.isNotEmpty
? filter.filename
: null,
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,
isArchived: filter.display.isArchive,
isFavorite: filter.display.isFavorite,
isNotInAlbum: filter.display.isNotInAlbum,
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
size: 1000,
),
);
}
if (response == null) {
return null;
}
// TODO local DB might be out of date; add assets not yet in DB?
return _db.assets.getAllByRemoteId(results.assets.items.map((e) => e.id));
} catch (e) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null;
return _db.assets
.getAllByRemoteId(response.assets.items.map((e) => e.id));
} catch (error) {
debugPrint("Error [search] $error");
}
return null;
}
Future<List<CuratedLocationsResponseDto>?> getCuratedLocation() async {

View File

@ -1,8 +1,10 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@ -57,7 +59,22 @@ class ExploreGrid extends StatelessWidget {
),
)
: context.pushRoute(
SearchResultRoute(searchTerm: 'm:${content.label}'),
SearchInputRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(
city: content.label,
),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: AssetType.other,
),
),
);
},
);

View File

@ -1,99 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class ImmichSearchBar extends HookConsumerWidget
implements PreferredSizeWidget {
const ImmichSearchBar({
super.key,
required this.searchFocusNode,
required this.onSubmitted,
});
final FocusNode searchFocusNode;
final Function(String) onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchTermController = useTextEditingController(text: "");
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
focusSearch() {
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
ref.watch(searchPageStateProvider.notifier).enableSearch();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
searchFocusNode.requestFocus();
}
useEffect(
() {
searchFocusNotifier.addListener(focusSearch);
return () {
searchFocusNotifier.removeListener(focusSearch);
};
},
[],
);
return AppBar(
automaticallyImplyLeading: false,
leading: isSearchEnabled
? IconButton(
onPressed: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
searchTermController.clear();
},
icon: const Icon(Icons.arrow_back_ios_rounded),
)
: const Icon(
Icons.search_rounded,
size: 20,
),
title: TextField(
controller: searchTermController,
focusNode: searchFocusNode,
autofocus: false,
onTap: focusSearch,
onSubmitted: (searchTerm) {
onSubmitted(searchTerm);
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
},
onChanged: (value) {
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
},
decoration: InputDecoration(
hintText: 'search_bar_hint'.tr(),
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.themeData.colorScheme.onSurface.withOpacity(0.75),
),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
// Used to focus search from outside this widget.
// For example when double pressing the search nav icon.
final searchFocusNotifier = SearchFocusNotifier();
class SearchFocusNotifier with ChangeNotifier {
void requestFocus() {
notifyListeners();
}
}

View File

@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/search/providers/search_filter.provider.dart';
import 'package:openapi/api.dart';
class CameraPicker extends HookConsumerWidget {
const CameraPicker({super.key, required this.onSelect, this.filter});
final Function(Map<String, String?>) onSelect;
final SearchCameraFilter? filter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final makeTextController = useTextEditingController(text: filter?.make);
final modelTextController = useTextEditingController(text: filter?.model);
final selectedMake = useState<String?>(filter?.make);
final selectedModel = useState<String?>(filter?.model);
final make = ref.watch(
getSearchSuggestionsProvider(
SearchSuggestionType.cameraMake,
),
);
final models = ref.watch(
getSearchSuggestionsProvider(
SearchSuggestionType.cameraModel,
make: selectedMake.value,
),
);
final inputDecorationTheme = InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.only(left: 16),
);
final menuStyle = MenuStyle(
shape: MaterialStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
return Container(
padding: const EdgeInsets.only(
// bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DropdownMenu(
dropdownMenuEntries: switch (make) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
width: context.width * 0.45,
menuHeight: 400,
label: const Text('Make'),
inputDecorationTheme: inputDecorationTheme,
controller: makeTextController,
menuStyle: menuStyle,
leadingIcon: const Icon(Icons.photo_camera_rounded),
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
selectedMake.value = value.toString();
onSelect({
'make': selectedMake.value,
'model': selectedModel.value,
});
},
),
DropdownMenu(
dropdownMenuEntries: switch (models) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
width: context.width * 0.45,
menuHeight: 400,
label: const Text('Model'),
inputDecorationTheme: inputDecorationTheme,
controller: modelTextController,
menuStyle: menuStyle,
leadingIcon: const Icon(Icons.camera),
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
selectedModel.value = value.toString();
onSelect({
'make': selectedMake.value,
'model': selectedModel.value,
});
},
),
],
),
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
enum DisplayOption {
notInAlbum,
favorite,
archive,
}
class DisplayOptionPicker extends HookWidget {
const DisplayOptionPicker({
super.key,
required this.onSelect,
this.filter,
});
final Function(Map<DisplayOption, bool>) onSelect;
final SearchDisplayFilters? filter;
@override
Widget build(BuildContext context) {
final options = useState<Map<DisplayOption, bool>>({
DisplayOption.notInAlbum: filter?.isNotInAlbum ?? false,
DisplayOption.favorite: filter?.isFavorite ?? false,
DisplayOption.archive: filter?.isArchive ?? false,
});
return ListView(
shrinkWrap: true,
children: [
CheckboxListTile(
title: const Text('Not in album'),
value: options.value[DisplayOption.notInAlbum],
onChanged: (bool? value) {
options.value = {
...options.value,
DisplayOption.notInAlbum: value!,
};
onSelect(options.value);
},
),
CheckboxListTile(
title: const Text('Favorite'),
value: options.value[DisplayOption.favorite],
onChanged: (value) {
options.value = {
...options.value,
DisplayOption.favorite: value!,
};
onSelect(options.value);
},
),
CheckboxListTile(
title: const Text('Archive'),
value: options.value[DisplayOption.archive],
onChanged: (value) {
options.value = {
...options.value,
DisplayOption.archive: value!,
};
onSelect(options.value);
},
),
],
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class FilterBottomSheetScaffold extends StatelessWidget {
const FilterBottomSheetScaffold({
super.key,
required this.child,
required this.onSearch,
required this.onClear,
required this.title,
this.expanded,
});
final bool? expanded;
final String title;
final Widget child;
final Function() onSearch;
final Function() onClear;
@override
Widget build(BuildContext context) {
buildChildWidget() {
if (expanded != null && expanded == true) {
return Expanded(child: child);
}
return child;
}
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
title,
style: context.textTheme.headlineSmall,
),
),
buildChildWidget(),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () {
onClear();
Navigator.of(context).pop();
},
child: const Text('Clear'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
onSearch();
Navigator.of(context).pop();
},
child: const Text('Apply filter'),
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/search/providers/search_filter.provider.dart';
import 'package:openapi/api.dart';
class LocationPicker extends HookConsumerWidget {
const LocationPicker({super.key, required this.onSelected, this.filter});
final Function(Map<String, String?>) onSelected;
final SearchLocationFilter? filter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final countryTextController =
useTextEditingController(text: filter?.country);
final stateTextController = useTextEditingController(text: filter?.state);
final cityTextController = useTextEditingController(text: filter?.city);
final selectedCountry = useState<String?>(filter?.country);
final selectedState = useState<String?>(filter?.state);
final selectedCity = useState<String?>(filter?.city);
final countries = ref.watch(
getSearchSuggestionsProvider(
SearchSuggestionType.country,
locationCountry: selectedCountry.value,
locationState: selectedState.value,
),
);
final states = ref.watch(
getSearchSuggestionsProvider(
SearchSuggestionType.state,
locationCountry: selectedCountry.value,
locationState: selectedState.value,
),
);
final cities = ref.watch(
getSearchSuggestionsProvider(
SearchSuggestionType.city,
locationCountry: selectedCountry.value,
locationState: selectedState.value,
),
);
final inputDecorationTheme = InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.only(left: 16),
);
final menuStyle = MenuStyle(
shape: MaterialStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
return Column(
children: [
DropdownMenu(
dropdownMenuEntries: switch (countries) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
menuHeight: 400,
width: context.width * 0.9,
label: const Text('Country'),
inputDecorationTheme: inputDecorationTheme,
menuStyle: menuStyle,
controller: countryTextController,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
selectedCountry.value = value.toString();
onSelected({
'country': selectedCountry.value,
'state': selectedState.value,
'city': selectedCity.value,
});
},
),
const SizedBox(
height: 16,
),
DropdownMenu(
dropdownMenuEntries: switch (states) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
menuHeight: 400,
width: context.width * 0.9,
label: const Text('State'),
inputDecorationTheme: inputDecorationTheme,
menuStyle: menuStyle,
controller: stateTextController,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
selectedState.value = value.toString();
onSelected({
'country': selectedCountry.value,
'state': selectedState.value,
'city': selectedCity.value,
});
},
),
const SizedBox(
height: 16,
),
DropdownMenu(
dropdownMenuEntries: switch (cities) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
menuHeight: 400,
width: context.width * 0.9,
label: const Text('City'),
inputDecorationTheme: inputDecorationTheme,
menuStyle: menuStyle,
controller: cityTextController,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
selectedCity.value = value.toString();
onSelected({
'country': selectedCountry.value,
'state': selectedState.value,
'city': selectedCity.value,
});
},
),
],
);
}
}

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class MediaTypePicker extends HookWidget {
const MediaTypePicker({super.key, required this.onSelect, this.filter});
final Function(AssetType) onSelect;
final AssetType? filter;
@override
Widget build(BuildContext context) {
final selectedMediaType = useState(filter ?? AssetType.other);
return ListView(
shrinkWrap: true,
children: [
RadioListTile(
title: const Text("All"),
value: AssetType.other,
onChanged: (value) {
selectedMediaType.value = value!;
onSelect(value);
},
groupValue: selectedMediaType.value,
),
RadioListTile(
title: const Text("Image"),
value: AssetType.image,
onChanged: (value) {
selectedMediaType.value = value!;
onSelect(value);
},
groupValue: selectedMediaType.value,
),
RadioListTile(
title: const Text("Video"),
value: AssetType.video,
onChanged: (value) {
selectedMediaType.value = value!;
onSelect(value);
},
groupValue: selectedMediaType.value,
),
],
);
}
}

View File

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/shared/models/store.dart' as local_store;
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class PeoplePicker extends HookConsumerWidget {
const PeoplePicker({super.key, required this.onSelect, this.filter});
final Function(Set<PersonResponseDto>) onSelect;
final Set<PersonResponseDto>? filter;
@override
Widget build(BuildContext context, WidgetRef ref) {
var imageSize = 45.0;
final people = ref.watch(getAllPeopleProvider);
final headers = {
"x-immich-user-token":
local_store.Store.get(local_store.StoreKey.accessToken),
};
final selectedPeople = useState<Set<PersonResponseDto>>(filter ?? {});
return people.widgetWhen(
onData: (people) {
return ListView.builder(
shrinkWrap: true,
itemCount: people.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final person = people[index];
return Card(
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
child: ListTile(
title: Text(
person.name,
style: context.textTheme.bodyLarge,
),
leading: SizedBox(
height: imageSize,
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: headers,
),
),
),
),
onTap: () {
if (selectedPeople.value.contains(person)) {
selectedPeople.value.remove(person);
} else {
selectedPeople.value.add(person);
}
selectedPeople.value = {...selectedPeople.value};
onSelect(selectedPeople.value);
},
selected: selectedPeople.value.contains(person),
selectedTileColor: context.primaryColor.withOpacity(0.2),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
),
);
},
);
},
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SearchFilterChip extends StatelessWidget {
final String label;
final Function() onTap;
final Widget? currentFilter;
final IconData icon;
const SearchFilterChip({
super.key,
required this.label,
required this.onTap,
required this.icon,
this.currentFilter,
});
@override
Widget build(BuildContext context) {
if (currentFilter != null) {
return GestureDetector(
onTap: onTap,
child: Card(
elevation: 0,
color: context.primaryColor.withAlpha(25),
shape: StadiumBorder(
side: BorderSide(color: context.primaryColor),
),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0),
child: Row(
children: [
Icon(
icon,
size: 18,
),
const SizedBox(width: 4.0),
currentFilter!,
],
),
),
),
);
}
return GestureDetector(
onTap: onTap,
child: Card(
elevation: 0,
shape:
StadiumBorder(side: BorderSide(color: Colors.grey.withAlpha(100))),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0),
child: Row(
children: [
Icon(
icon,
size: 18,
),
const SizedBox(width: 4.0),
Text(label),
],
),
),
),
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
Future<T> showFilterBottomSheet<T>({
required BuildContext context,
required Widget child,
bool isScrollControlled = false,
bool isDismissible = true,
}) async {
return await showModalBottomSheet(
context: context,
isScrollControlled: isScrollControlled,
useSafeArea: false,
isDismissible: isDismissible,
showDragHandle: isDismissible,
builder: (BuildContext context) {
return child;
},
);
}

View File

@ -1,66 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class SearchSuggestionList extends ConsumerWidget {
const SearchSuggestionList({super.key, required this.onSubmitted});
final Function(String) onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
final searchSuggestion =
ref.watch(searchPageStateProvider).searchSuggestion;
return Container(
color: searchTerm.isEmpty
? Colors.black.withOpacity(0.5)
: context.scaffoldBackgroundColor,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Container(
color: context.isDarkTheme ? Colors.grey[800] : Colors.grey[100],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: 'search_suggestion_list_smart_search_hint_1'.tr(),
style: context.textTheme.bodyMedium,
),
TextSpan(
text: 'search_suggestion_list_smart_search_hint_2'.tr(),
style: context.textTheme.bodyMedium?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
),
SliverFillRemaining(
hasScrollBody: true,
child: ListView.builder(
itemBuilder: ((context, index) {
return ListTile(
onTap: () {
onSubmitted("m:${searchSuggestion[index]}");
},
title: Text(searchSuggestion[index]),
);
}),
itemCount: searchSuggestion.length,
),
),
],
),
);
}
}

View File

@ -1,35 +1,38 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
@RoutePage()
class AllPeoplePage extends HookConsumerWidget {
const AllPeoplePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final curatedPeople = ref.watch(getCuratedPeopleProvider);
return Scaffold(
appBar: AppBar(
title: const Text(
'all_people_page_title',
).tr(),
leading: IconButton(
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: curatedPeople.widgetWhen(
onData: (people) => ExploreGrid(
isPeople: true,
curatedContent: people,
),
),
);
}
}
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
@RoutePage()
class AllPeoplePage extends HookConsumerWidget {
const AllPeoplePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final curatedPeople = ref.watch(getAllPeopleProvider);
return Scaffold(
appBar: AppBar(
title: const Text(
'all_people_page_title',
).tr(),
leading: IconButton(
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: curatedPeople.widgetWhen(
onData: (people) => ExploreGrid(
isPeople: true,
curatedContent: people
.map((e) => CuratedContent(label: e.name, id: e.id))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,563 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/search/providers/paginated_search.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_filter/camera_picker.dart';
import 'package:immich_mobile/modules/search/ui/search_filter/display_option_picker.dart';
import 'package:immich_mobile/modules/search/ui/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/modules/search/ui/search_filter/location_picker.dart';
import 'package:immich_mobile/modules/search/ui/search_filter/media_type_picker.dart';
import 'package:immich_mobile/modules/search/ui/search_filter/people_picker.dart';
import 'package:immich_mobile/modules/search/ui/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/modules/search/ui/search_filter/search_filter_utils.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:openapi/api.dart';
@RoutePage()
class SearchInputPage extends HookConsumerWidget {
const SearchInputPage({super.key, this.prefilter});
final SearchFilter? prefilter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isContextualSearch = useState(true);
final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>(
SearchFilter(
people: prefilter?.people ?? {},
location: prefilter?.location ?? SearchLocationFilter(),
camera: prefilter?.camera ?? SearchCameraFilter(),
date: prefilter?.date ?? SearchDateFilter(),
display: prefilter?.display ??
SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: prefilter?.mediaType ?? AssetType.other,
),
);
final previousFilter = useState(filter.value);
final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final currentPage = useState(1);
final searchProvider = ref.watch(paginatedSearchProvider);
final searchResultCount = useState(0);
search() async {
if (prefilter == null && filter.value == previousFilter.value) return;
ref.watch(paginatedSearchProvider.notifier).clear();
currentPage.value = 1;
final searchResult = await ref
.watch(paginatedSearchProvider.notifier)
.getNextPage(filter.value, currentPage.value);
previousFilter.value = filter.value;
searchResultCount.value = searchResult.length;
}
searchPrefilter() {
if (prefilter != null) {
Future.delayed(
Duration.zero,
() {
search();
if (prefilter!.location.city != null) {
locationCurrentFilterWidget.value = Text(
prefilter!.location.city!,
style: context.textTheme.labelLarge,
);
}
},
);
}
}
useEffect(
() {
searchPrefilter();
return null;
},
[],
);
loadMoreSearchResult() async {
currentPage.value += 1;
final searchResult = await ref
.watch(paginatedSearchProvider.notifier)
.getNextPage(filter.value, currentPage.value);
searchResultCount.value = searchResult.length;
}
showPeoplePicker() {
handleOnSelect(Set<PersonResponseDto> value) {
filter.value = filter.value.copyWith(
people: value,
);
peopleCurrentFilterWidget.value = Text(
value.map((e) => e.name != '' ? e.name : "No name").join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
people: {},
);
peopleCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FractionallySizedBox(
heightFactor: 0.8,
child: FilterBottomSheetScaffold(
title: 'Select people',
expanded: true,
onSearch: search,
onClear: handleClear,
child: PeoplePicker(
onSelect: handleOnSelect,
filter: filter.value.people,
),
),
),
);
}
showLocationPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(
country: value['country'],
city: value['city'],
state: value['state'],
),
);
final locationText = <String>[];
if (value['country'] != null) {
locationText.add(value['country']!);
}
if (value['state'] != null) {
locationText.add(value['state']!);
}
if (value['city'] != null) {
locationText.add(value['city']!);
}
locationCurrentFilterWidget.value = Text(
locationText.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(),
);
locationCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: false,
child: FilterBottomSheetScaffold(
title: 'Select location',
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: LocationPicker(
onSelected: handleOnSelect,
filter: filter.value.location,
),
),
),
),
);
}
showCameraPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(
make: value['make'],
model: value['model'],
),
);
cameraCurrentFilterWidget.value = Text(
'${value['make'] ?? ''} ${value['model'] ?? ''}',
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(),
);
cameraCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: false,
child: FilterBottomSheetScaffold(
title: 'Select camera type',
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: CameraPicker(
onSelect: handleOnSelect,
filter: filter.value.camera,
),
),
),
);
}
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
final date = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
),
helpText: 'Select a date range',
cancelText: 'Cancel',
confirmText: 'Select',
saveText: 'Save',
errorFormatText: 'Invalid date format',
errorInvalidText: 'Invalid date',
fieldStartHintText: 'Start date',
fieldEndHintText: 'End date',
initialEntryMode: DatePickerEntryMode.input,
);
if (date == null) {
filter.value = filter.value.copyWith(
date: SearchDateFilter(),
);
dateRangeCurrentFilterWidget.value = null;
search();
return;
}
filter.value = filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(
const Duration(
hours: 23,
minutes: 59,
seconds: 59,
),
),
),
);
// If date range is less than 24 hours, set the end date to the end of the day
if (date.end.difference(date.start).inHours < 24) {
dateRangeCurrentFilterWidget.value = Text(
date.start.toLocal().toIso8601String().split('T').first,
style: context.textTheme.labelLarge,
);
} else {
dateRangeCurrentFilterWidget.value = Text(
'${date.start.toLocal().toIso8601String().split('T').first} to ${date.end.toLocal().toIso8601String().split('T').first}',
style: context.textTheme.labelLarge,
);
}
search();
}
// MEDIA PICKER
showMediaTypePicker() {
handleOnSelected(AssetType assetType) {
filter.value = filter.value.copyWith(
mediaType: assetType,
);
mediaTypeCurrentFilterWidget.value = Text(
assetType == AssetType.image ? 'Image' : 'Video',
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
mediaType: AssetType.other,
);
mediaTypeCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'Select media type',
onSearch: search,
onClear: handleClear,
child: MediaTypePicker(
onSelect: handleOnSelected,
filter: filter.value.mediaType,
),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
final filterText = <String>[];
value.forEach((key, value) {
switch (key) {
case DisplayOption.notInAlbum:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isNotInAlbum: value,
),
);
if (value) filterText.add('Not in album');
break;
case DisplayOption.archive:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isArchive: value,
),
);
if (value) filterText.add('Archive');
break;
case DisplayOption.favorite:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isFavorite: value,
),
);
if (value) filterText.add('Favorite');
break;
}
});
displayOptionCurrentFilterWidget.value = Text(
filterText.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
);
displayOptionCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'Display options',
onSearch: search,
onClear: handleClear,
child: DisplayOptionPicker(
onSelect: handleOnSelect,
filter: filter.value.display,
),
),
);
}
handleTextSubmitted(String value) {
if (isContextualSearch.value) {
filter.value = filter.value.copyWith(
context: value,
filename: null,
);
} else {
filter.value = filter.value.copyWith(filename: value, context: null);
}
search();
}
buildSearchResult() {
return switch (searchProvider) {
AsyncData() => Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
final shouldLoadMore = searchResultCount.value > 75;
if (metrics.pixels >= metrics.maxScrollExtent &&
shouldLoadMore) {
loadMoreSearchResult();
}
return true;
},
child: MultiselectGrid(
renderListProvider: paginatedSearchRenderListProvider,
archiveEnabled: true,
deleteEnabled: true,
editEnabled: true,
favoriteEnabled: true,
stackEnabled: false,
emptyIndicator: const SizedBox(),
),
),
),
),
AsyncError(:final error) => Text('Error: $error'),
_ => const Expanded(child: Center(child: CircularProgressIndicator())),
};
}
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
automaticallyImplyLeading: true,
actions: [
IconButton(
icon: isContextualSearch.value
? const Icon(Icons.abc_rounded)
: const Icon(Icons.image_search_rounded),
onPressed: () {
isContextualSearch.value = !isContextualSearch.value;
textSearchController.clear();
},
),
],
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () {
context.router.pop();
},
),
title: TextField(
controller: textSearchController,
decoration: InputDecoration(
hintText: isContextualSearch.value
? 'Sunrise on the beach'
: 'File name or extension',
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.themeData.colorScheme.onSurface.withOpacity(0.75),
fontWeight: FontWeight.w500,
),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
),
onSubmitted: handleTextSubmitted,
),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: SizedBox(
height: 50,
child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SearchFilterChip(
icon: Icons.people_alt_rounded,
onTap: showPeoplePicker,
label: 'People',
currentFilter: peopleCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.location_pin,
onTap: showLocationPicker,
label: 'Location',
currentFilter: locationCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.camera_alt_rounded,
onTap: showCameraPicker,
label: 'Camera',
currentFilter: cameraCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.date_range_rounded,
onTap: showDatePicker,
label: 'Date',
currentFilter: dateRangeCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'Media Type',
currentFilter: mediaTypeCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label: 'Display Options',
currentFilter: displayOptionCurrentFilterWidget.value,
),
],
),
),
),
buildSearchResult(),
],
),
);
}
}

View File

@ -1,279 +1,274 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/curated_people_row.dart';
import 'package:immich_mobile/modules/search/ui/curated_places_row.dart';
import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart';
import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/scaffold_error_body.dart';
@RoutePage()
// ignore: must_be_immutable
class SearchPage extends HookConsumerWidget {
SearchPage({super.key});
FocusNode searchFocusNode = FocusNode();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
final curatedLocation = ref.watch(getCuratedLocationProvider);
final curatedPeople = ref.watch(getCuratedPeopleProvider);
final isMapEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map));
double imageSize = math.min(context.width / 3, 150);
TextStyle categoryTitleStyle = const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15.0,
);
Color categoryIconColor = context.isDarkTheme ? Colors.white : Colors.black;
useEffect(
() {
searchFocusNode = FocusNode();
return () => searchFocusNode.dispose();
},
[],
);
onSearchSubmitted(String searchTerm) async {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
context.pushRoute(
SearchResultRoute(
searchTerm: searchTerm,
),
);
}
showNameEditModel(
String personId,
String personName,
) {
return showDialog(
context: context,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
);
}
buildPeople() {
return SizedBox(
height: imageSize,
child: curatedPeople.widgetWhen(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
onData: (people) => Padding(
padding: const EdgeInsets.only(
left: 16,
top: 8,
),
child: CuratedPeopleRow(
content: people.take(12).toList(),
onTap: (content, index) {
context.pushRoute(
PersonResultRoute(
personId: content.id,
personName: content.label,
),
);
},
onNameTap: (person, index) => {
showNameEditModel(person.id, person.label),
},
),
),
),
);
}
buildPlaces() {
return SizedBox(
height: imageSize,
child: curatedLocation.widgetWhen(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
onData: (locations) => CuratedPlacesRow(
isMapEnabled: isMapEnabled,
content: locations
.map(
(o) => CuratedContent(
id: o.id,
label: o.city,
),
)
.toList(),
imageSize: imageSize,
onTap: (content, index) {
context.pushRoute(
SearchResultRoute(
searchTerm: 'm:${content.label}',
),
);
},
),
),
);
}
return Scaffold(
appBar: ImmichSearchBar(
searchFocusNode: searchFocusNode,
onSubmitted: onSearchSubmitted,
),
body: GestureDetector(
onTap: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
},
child: Stack(
children: [
ListView(
children: [
SearchRowTitle(
title: "search_page_people".tr(),
onViewAllPressed: () =>
context.pushRoute(const AllPeopleRoute()),
),
buildPeople(),
SearchRowTitle(
title: "search_page_places".tr(),
onViewAllPressed: () =>
context.pushRoute(const CuratedLocationRoute()),
top: 0,
),
const SizedBox(height: 10.0),
buildPlaces(),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'search_page_your_activity',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
ListTile(
leading: Icon(
Icons.favorite_border_rounded,
color: categoryIconColor,
),
title:
Text('search_page_favorites', style: categoryTitleStyle)
.tr(),
onTap: () => context.pushRoute(const FavoritesRoute()),
),
const CategoryDivider(),
ListTile(
leading: Icon(
Icons.schedule_outlined,
color: categoryIconColor,
),
title: Text(
'search_page_recently_added',
style: categoryTitleStyle,
).tr(),
onTap: () => context.pushRoute(const RecentlyAddedRoute()),
),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'search_page_categories',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
ListTile(
title:
Text('search_page_screenshots', style: categoryTitleStyle)
.tr(),
leading: Icon(
Icons.screenshot,
color: categoryIconColor,
),
onTap: () => context.pushRoute(
SearchResultRoute(
searchTerm: 'screenshots',
),
),
),
const CategoryDivider(),
ListTile(
title: Text('search_page_selfies', style: categoryTitleStyle)
.tr(),
leading: Icon(
Icons.photo_camera_front_outlined,
color: categoryIconColor,
),
onTap: () => context.pushRoute(
SearchResultRoute(
searchTerm: 'selfies',
),
),
),
const CategoryDivider(),
ListTile(
title: Text('search_page_videos', style: categoryTitleStyle)
.tr(),
leading: Icon(
Icons.play_circle_outline,
color: categoryIconColor,
),
onTap: () => context.pushRoute(const AllVideosRoute()),
),
const CategoryDivider(),
ListTile(
title: Text(
'search_page_motion_photos',
style: categoryTitleStyle,
).tr(),
leading: Icon(
Icons.motion_photos_on_outlined,
color: categoryIconColor,
),
onTap: () => context.pushRoute(const AllMotionPhotosRoute()),
),
],
),
if (isSearchEnabled)
SearchSuggestionList(onSubmitted: onSearchSubmitted),
],
),
),
);
}
}
class CategoryDivider extends StatelessWidget {
const CategoryDivider({super.key});
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(
left: 56,
right: 16,
),
child: Divider(
height: 0,
),
);
}
}
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/curated_people_row.dart';
import 'package:immich_mobile/modules/search/ui/curated_places_row.dart';
import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
import 'package:immich_mobile/shared/ui/scaffold_error_body.dart';
@RoutePage()
// ignore: must_be_immutable
class SearchPage extends HookConsumerWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final curatedLocation = ref.watch(getCuratedLocationProvider);
final curatedPeople = ref.watch(getAllPeopleProvider);
final isMapEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map));
double imageSize = math.min(context.width / 3, 150);
TextStyle categoryTitleStyle = const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15.0,
);
Color categoryIconColor = context.isDarkTheme ? Colors.white : Colors.black;
showNameEditModel(
String personId,
String personName,
) {
return showDialog(
context: context,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
);
}
buildPeople() {
return SizedBox(
height: imageSize,
child: curatedPeople.widgetWhen(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
onData: (people) => Padding(
padding: const EdgeInsets.only(
left: 16,
top: 8,
),
child: CuratedPeopleRow(
content: people
.map((e) => CuratedContent(label: e.name, id: e.id))
.take(12)
.toList(),
onTap: (content, index) {
context.pushRoute(
PersonResultRoute(
personId: content.id,
personName: content.label,
),
);
},
onNameTap: (person, index) => {
showNameEditModel(person.id, person.label),
},
),
),
),
);
}
buildPlaces() {
return SizedBox(
height: imageSize,
child: curatedLocation.widgetWhen(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
onData: (locations) => CuratedPlacesRow(
isMapEnabled: isMapEnabled,
content: locations
.map(
(o) => CuratedContent(
id: o.id,
label: o.city,
),
)
.toList(),
imageSize: imageSize,
onTap: (content, index) {
context.pushRoute(
SearchInputRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(
city: content.label,
),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: AssetType.other,
),
),
);
},
),
),
);
}
buildSearchButton() {
return GestureDetector(
onTap: () {
context.pushRoute(SearchInputRoute());
},
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: context.isDarkTheme
? Colors.grey[800]!
: const Color.fromARGB(255, 225, 225, 225),
),
),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 12.0,
),
child: Row(
children: [
Icon(Icons.search, color: context.primaryColor),
const SizedBox(width: 16.0),
Text(
"Search your photos",
style: context.textTheme.bodyLarge?.copyWith(
color:
context.isDarkTheme ? Colors.white70 : Colors.black54,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
);
}
return Scaffold(
appBar: const ImmichAppBar(),
body: Stack(
children: [
ListView(
children: [
buildSearchButton(),
SearchRowTitle(
title: "search_page_people".tr(),
onViewAllPressed: () =>
context.pushRoute(const AllPeopleRoute()),
),
buildPeople(),
SearchRowTitle(
title: "search_page_places".tr(),
onViewAllPressed: () =>
context.pushRoute(const CuratedLocationRoute()),
top: 0,
),
const SizedBox(height: 10.0),
buildPlaces(),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'search_page_your_activity',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
ListTile(
leading: Icon(
Icons.favorite_border_rounded,
color: categoryIconColor,
),
title: Text('search_page_favorites', style: categoryTitleStyle)
.tr(),
onTap: () => context.pushRoute(const FavoritesRoute()),
),
const CategoryDivider(),
ListTile(
leading: Icon(
Icons.schedule_outlined,
color: categoryIconColor,
),
title: Text(
'search_page_recently_added',
style: categoryTitleStyle,
).tr(),
onTap: () => context.pushRoute(const RecentlyAddedRoute()),
),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'search_page_categories',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
ListTile(
title:
Text('search_page_videos', style: categoryTitleStyle).tr(),
leading: Icon(
Icons.play_circle_outline,
color: categoryIconColor,
),
onTap: () => context.pushRoute(const AllVideosRoute()),
),
const CategoryDivider(),
ListTile(
title: Text(
'search_page_motion_photos',
style: categoryTitleStyle,
).tr(),
leading: Icon(
Icons.motion_photos_on_outlined,
color: categoryIconColor,
),
onTap: () => context.pushRoute(const AllMotionPhotosRoute()),
),
],
),
],
),
);
}
}
class CategoryDivider extends StatelessWidget {
const CategoryDivider({super.key});
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(
left: 56,
right: 16,
),
child: Divider(
height: 0,
),
);
}
}

View File

@ -1,213 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class SearchType {
SearchType({required this.isSmart, required this.searchTerm});
final bool isSmart;
final String searchTerm;
}
SearchType _getSearchType(String searchTerm) {
if (searchTerm.startsWith('m:')) {
return SearchType(isSmart: false, searchTerm: searchTerm.substring(2));
} else {
return SearchType(isSmart: true, searchTerm: searchTerm);
}
}
@RoutePage()
class SearchResultPage extends HookConsumerWidget {
const SearchResultPage({
super.key,
required this.searchTerm,
});
final String searchTerm;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchTermController = useTextEditingController(text: "");
final isNewSearch = useState(false);
final currentSearchTerm = useState(searchTerm);
FocusNode? searchFocusNode;
useEffect(
() {
searchFocusNode = FocusNode();
var searchType = _getSearchType(searchTerm);
Future.delayed(
Duration.zero,
() => ref
.read(searchResultPageProvider.notifier)
.search(searchType.searchTerm, smartSearch: searchType.isSmart),
);
return () => searchFocusNode?.dispose();
},
[],
);
Future<void> onSearchSubmitted(String newSearchTerm) {
debugPrint("Re-Search with $newSearchTerm");
searchFocusNode?.unfocus();
isNewSearch.value = false;
currentSearchTerm.value = newSearchTerm;
var searchType = _getSearchType(newSearchTerm);
return ref
.watch(searchResultPageProvider.notifier)
.search(searchType.searchTerm, smartSearch: searchType.isSmart);
}
buildTextField() {
return TextField(
controller: searchTermController,
focusNode: searchFocusNode,
autofocus: false,
onTap: () {
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
searchFocusNode?.requestFocus();
},
textInputAction: TextInputAction.search,
onSubmitted: (searchTerm) {
if (searchTerm.isNotEmpty) {
searchTermController.clear();
onSearchSubmitted(searchTerm);
} else {
isNewSearch.value = false;
}
},
onChanged: (value) {
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
},
decoration: InputDecoration(
hintText: 'search_result_page_new_search_hint'.tr(),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
hintStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
color: context.isDarkTheme
? Colors.grey[500]
: Colors.black.withOpacity(0.5),
),
),
);
}
buildChip() {
return Chip(
label: Wrap(
spacing: 5,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
children: [
Text(
currentSearchTerm.value,
style: TextStyle(
color: context.primaryColor,
fontSize: 13,
fontWeight: FontWeight.bold,
),
maxLines: 1,
),
Icon(
Icons.close_rounded,
color: context.primaryColor,
size: 20,
),
],
),
backgroundColor: context.primaryColor.withAlpha(50),
);
}
Future<void> refresh() async => onSearchSubmitted(currentSearchTerm.value);
buildSearchResult() {
final searchResultPageState = ref.watch(searchResultPageProvider);
if (searchResultPageState.isError) {
return Padding(
padding: const EdgeInsets.all(12),
child: const Text("common_server_error").tr(),
);
}
if (searchResultPageState.isLoading) {
return const Center(child: ImmichLoadingIndicator());
}
if (searchResultPageState.isSuccess) {
return MultiselectGrid(
renderListProvider: searchRenderListProvider,
archiveEnabled: true,
deleteEnabled: true,
editEnabled: true,
favoriteEnabled: true,
stackEnabled: false,
onRefresh: refresh,
);
}
return const SizedBox();
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
splashRadius: 20,
onPressed: () {
if (isNewSearch.value) {
isNewSearch.value = false;
} else {
context.popRoute(true);
}
},
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: GestureDetector(
onTap: () {
isNewSearch.value = true;
searchFocusNode?.requestFocus();
},
child: isNewSearch.value ? buildTextField() : buildChip(),
),
centerTitle: false,
),
body: GestureDetector(
onTap: () {
if (searchFocusNode != null) {
searchFocusNode?.unfocus();
}
ref.watch(searchPageStateProvider.notifier).disableSearch();
},
child: Stack(
children: [
buildSearchResult(),
if (isNewSearch.value)
SearchSuggestionList(onSubmitted: onSearchSubmitted),
],
),
),
);
}
}

View File

@ -31,7 +31,9 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/settings/views/settings_sub_page.dart';
import 'package:immich_mobile/modules/search/views/search_input_page.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_edit_page.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_page.dart';
@ -43,7 +45,6 @@ import 'package:immich_mobile/modules/search/views/curated_location_page.dart';
import 'package:immich_mobile/modules/search/views/person_result_page.dart';
import 'package:immich_mobile/modules/search/views/recently_added_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/routing/custom_transition_builders.dart';
@ -125,10 +126,6 @@ class AppRouter extends _$AppRouter {
page: BackupControllerRoute.page,
guards: [_authGuard, _duplicateGuard, _backupPermissionGuard],
),
AutoRoute(
page: SearchResultRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: CuratedLocationRoute.page,
guards: [_authGuard, _duplicateGuard],
@ -223,6 +220,11 @@ class AppRouter extends _$AppRouter {
page: BackupOptionsRoute.page,
guards: [_authGuard, _duplicateGuard],
),
CustomRoute(
page: SearchInputRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.noTransition,
),
];
}

View File

@ -255,22 +255,21 @@ abstract class _$AppRouter extends RootStackRouter {
child: const RecentlyAddedPage(),
);
},
SearchRoute.name: (routeData) {
final args = routeData.argsAs<SearchRouteArgs>(
orElse: () => const SearchRouteArgs());
SearchInputRoute.name: (routeData) {
final args = routeData.argsAs<SearchInputRouteArgs>(
orElse: () => const SearchInputRouteArgs());
return AutoRoutePage<dynamic>(
routeData: routeData,
child: SearchPage(key: args.key),
child: SearchInputPage(
key: args.key,
prefilter: args.prefilter,
),
);
},
SearchResultRoute.name: (routeData) {
final args = routeData.argsAs<SearchResultRouteArgs>();
SearchRoute.name: (routeData) {
return AutoRoutePage<dynamic>(
routeData: routeData,
child: SearchResultPage(
key: args.key,
searchTerm: args.searchTerm,
),
child: const SearchPage(),
);
},
SelectAdditionalUserForSharingRoute.name: (routeData) {
@ -1113,69 +1112,55 @@ class RecentlyAddedRoute extends PageRouteInfo<void> {
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
SearchRoute({
/// [SearchInputPage]
class SearchInputRoute extends PageRouteInfo<SearchInputRouteArgs> {
SearchInputRoute({
Key? key,
SearchFilter? prefilter,
List<PageRouteInfo>? children,
}) : super(
SearchInputRoute.name,
args: SearchInputRouteArgs(
key: key,
prefilter: prefilter,
),
initialChildren: children,
);
static const String name = 'SearchInputRoute';
static const PageInfo<SearchInputRouteArgs> page =
PageInfo<SearchInputRouteArgs>(name);
}
class SearchInputRouteArgs {
const SearchInputRouteArgs({
this.key,
this.prefilter,
});
final Key? key;
final SearchFilter? prefilter;
@override
String toString() {
return 'SearchInputRouteArgs{key: $key, prefilter: $prefilter}';
}
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<void> {
const SearchRoute({List<PageRouteInfo>? children})
: super(
SearchRoute.name,
args: SearchRouteArgs(key: key),
initialChildren: children,
);
static const String name = 'SearchRoute';
static const PageInfo<SearchRouteArgs> page = PageInfo<SearchRouteArgs>(name);
}
class SearchRouteArgs {
const SearchRouteArgs({this.key});
final Key? key;
@override
String toString() {
return 'SearchRouteArgs{key: $key}';
}
}
/// generated route for
/// [SearchResultPage]
class SearchResultRoute extends PageRouteInfo<SearchResultRouteArgs> {
SearchResultRoute({
Key? key,
required String searchTerm,
List<PageRouteInfo>? children,
}) : super(
SearchResultRoute.name,
args: SearchResultRouteArgs(
key: key,
searchTerm: searchTerm,
),
initialChildren: children,
);
static const String name = 'SearchResultRoute';
static const PageInfo<SearchResultRouteArgs> page =
PageInfo<SearchResultRouteArgs>(name);
}
class SearchResultRouteArgs {
const SearchResultRouteArgs({
this.key,
required this.searchTerm,
});
final Key? key;
final String searchTerm;
@override
String toString() {
return 'SearchResultRouteArgs{key: $key, searchTerm: $searchTerm}';
}
static const PageInfo<void> page = PageInfo<void>(name);
}
/// generated route for

View File

@ -38,7 +38,7 @@ class TabNavigationObserver extends AutoRouterObserver {
if (route.name == 'SearchRoute') {
// Refresh Location State
ref.invalidate(getCuratedLocationProvider);
ref.invalidate(getCuratedPeopleProvider);
ref.invalidate(getAllPeopleProvider);
}
if (route.name == 'SharingRoute') {

View File

@ -43,6 +43,7 @@ class MultiselectGrid extends HookConsumerWidget {
this.editEnabled = false,
this.unarchive = false,
this.unfavorite = false,
this.emptyIndicator,
});
final ProviderListenable<AsyncValue<RenderList>> renderListProvider;
@ -57,12 +58,12 @@ class MultiselectGrid extends HookConsumerWidget {
final bool favoriteEnabled;
final bool unfavorite;
final bool editEnabled;
final Widget? emptyIndicator;
Widget buildDefaultLoadingIndicator() =>
const Center(child: ImmichLoadingIndicator());
Widget buildEmptyIndicator() =>
const Center(child: Text("No assets to show"));
emptyIndicator ?? const Center(child: Text("No assets to show"));
@override
Widget build(BuildContext context, WidgetRef ref) {

View File

@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/tab.provider.dart';
@ -53,10 +52,6 @@ class TabControllerPage extends HookConsumerWidget {
// Scroll to top
scrollToTopNotifierProvider.scrollToTop();
}
if (tabsRouter.activeIndex == 1 && index == 1) {
// Focus search
searchFocusNotifier.requestFocus();
}
HapticFeedback.selectionClick();
tabsRouter.setActiveIndex(index);
@ -111,10 +106,7 @@ class TabControllerPage extends HookConsumerWidget {
// Scroll to top
scrollToTopNotifierProvider.scrollToTop();
}
if (tabsRouter.activeIndex == 1 && index == 1) {
// Focus search
searchFocusNotifier.requestFocus();
}
HapticFeedback.selectionClick();
tabsRouter.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
@ -170,11 +162,11 @@ class TabControllerPage extends HookConsumerWidget {
final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter(
routes: [
const HomeRoute(),
routes: const [
HomeRoute(),
SearchRoute(),
const SharingRoute(),
const LibraryRoute(),
SharingRoute(),
LibraryRoute(),
],
duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition(

View File

@ -33,6 +33,9 @@ final ThemeData base = ThemeData(
final ThemeData immichLightTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
),
primarySwatch: Colors.indigo,
primaryColor: Colors.indigo,
hintColor: Colors.indigo,
@ -158,6 +161,10 @@ final ThemeData immichDarkTheme = ThemeData(
brightness: Brightness.dark,
primarySwatch: Colors.indigo,
primaryColor: immichDarkThemePrimaryColor,
colorScheme: ColorScheme.fromSeed(
seedColor: immichDarkThemePrimaryColor,
brightness: Brightness.dark,
),
scaffoldBackgroundColor: immichDarkBackgroundColor,
hintColor: Colors.grey[600],
fontFamily: 'Overpass',

File diff suppressed because it is too large Load Diff