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

View file

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

View file

@ -1,15 +1,60 @@
/// A wrapper for [CuratedLocationsResponseDto] objects // ignore_for_file: public_member_api_docs, sort_constructors_first
/// and [CuratedObjectsResponseDto] to be displayed in import 'dart:convert';
/// a view
class CuratedContent { /// A wrapper for [CuratedLocationsResponseDto] objects
/// The label to show associated with this curated object /// and [CuratedObjectsResponseDto] to be displayed in
final String label; /// a view
class CuratedContent {
/// The id to lookup the asset from the server /// The label to show associated with this curated object
final String id; final String label;
CuratedContent({ /// The id to lookup the asset from the server
required this.id, final String id;
required this.label,
}); 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/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/search/services/person.service.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.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:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:openapi/api.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'people.provider.g.dart'; part 'people.provider.g.dart';
@riverpod @riverpod
Future<List<CuratedContent>> getCuratedPeople( Future<List<PersonResponseDto>> getAllPeople(
GetCuratedPeopleRef ref, GetAllPeopleRef ref,
) async { ) async {
final PersonService personService = ref.read(personServiceProvider); final PersonService personService = ref.read(personServiceProvider);
final curatedPeople = await personService.getCuratedPeople(); final people = await personService.getAllPeople();
return curatedPeople return people;
.map((p) => CuratedContent(id: p.id, label: p.name)) }
.toList();
} @riverpod
Future<RenderList> personAssets(PersonAssetsRef ref, String personId) async {
@riverpod final PersonService personService = ref.read(personServiceProvider);
Future<RenderList> personAssets(PersonAssetsRef ref, String personId) async { final assets = await personService.getPersonAssets(personId);
final PersonService personService = ref.read(personServiceProvider); if (assets == null) {
final assets = await personService.getPersonAssets(personId); return RenderList.empty();
if (assets == null) { }
return RenderList.empty();
} final settings = ref.read(appSettingsServiceProvider);
final groupBy =
final settings = ref.read(appSettingsServiceProvider); GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
final groupBy = return await RenderList.fromAssets(assets, groupBy);
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; }
return await RenderList.fromAssets(assets, groupBy);
} @riverpod
Future<bool> updatePersonName(
@riverpod UpdatePersonNameRef ref,
Future<bool> updatePersonName( String personId,
UpdatePersonNameRef ref, String updatedName,
String personId, ) async {
String updatedName, final PersonService personService = ref.read(personServiceProvider);
) async { final person = await personService.updateName(personId, updatedName);
final PersonService personService = ref.read(personServiceProvider);
final person = await personService.updateName(personId, updatedName); if (person != null && person.name == updatedName) {
ref.invalidate(getAllPeopleProvider);
if (person != null && person.name == updatedName) { return true;
ref.invalidate(getCuratedPeopleProvider); }
return true; return false;
} }
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); PersonService(this._apiService, this._db);
Future<List<PersonResponseDto>> getCuratedPeople() async { Future<List<PersonResponseDto>> getAllPeople() async {
try { try {
final peopleResponseDto = await _apiService.personApi.getAllPeople(); final peopleResponseDto = await _apiService.personApi.getAllPeople();
return peopleResponseDto?.people ?? []; return peopleResponseDto?.people ?? [];

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart';
@ -29,25 +30,92 @@ class SearchService {
} }
} }
Future<List<Asset>?> searchAsset( Future<List<String>?> getSearchSuggestions(
String searchTerm, { SearchSuggestionType type, {
bool smartSearch = true, String? country,
String? state,
String? make,
String? model,
}) async { }) async {
// TODO search in local DB: 1. when offline, 2. to find local assets
try { try {
final SearchResponseDto? results = await _apiService.searchApi.search( return await _apiService.searchApi.getSearchSuggestions(
query: searchTerm, type,
smart: smartSearch, 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; 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)); return _db.assets
} catch (e) { .getAllByRemoteId(response.assets.items.map((e) => e.id));
debugPrint("[ERROR] [searchAsset] ${e.toString()}"); } catch (error) {
return null; debugPrint("Error [search] $error");
} }
return null;
} }
Future<List<CuratedLocationsResponseDto>?> getCuratedLocation() async { Future<List<CuratedLocationsResponseDto>?> getCuratedLocation() async {

View file

@ -1,8 +1,10 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/search/models/curated_content.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/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.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/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
@ -57,7 +59,22 @@ class ExploreGrid extends StatelessWidget {
), ),
) )
: context.pushRoute( : 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:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.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/models/curated_content.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.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 { @RoutePage()
const AllPeoplePage({super.key}); class AllPeoplePage extends HookConsumerWidget {
const AllPeoplePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) { @override
final curatedPeople = ref.watch(getCuratedPeopleProvider); Widget build(BuildContext context, WidgetRef ref) {
final curatedPeople = ref.watch(getAllPeopleProvider);
return Scaffold(
appBar: AppBar( return Scaffold(
title: const Text( appBar: AppBar(
'all_people_page_title', title: const Text(
).tr(), 'all_people_page_title',
leading: IconButton( ).tr(),
onPressed: () => context.popRoute(), leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_rounded), onPressed: () => context.popRoute(),
), icon: const Icon(Icons.arrow_back_ios_rounded),
), ),
body: curatedPeople.widgetWhen( ),
onData: (people) => ExploreGrid( body: curatedPeople.widgetWhen(
isPeople: true, onData: (people) => ExploreGrid(
curatedContent: people, 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 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_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/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart'; import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/curated_places_row.dart'; import 'package:immich_mobile/modules/search/ui/curated_people_row.dart';
import 'package:immich_mobile/modules/search/ui/immich_search_bar.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/person_name_edit_form.dart';
import 'package:immich_mobile/modules/search/ui/search_row_title.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/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/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/scaffold_error_body.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 @RoutePage()
class SearchPage extends HookConsumerWidget { // ignore: must_be_immutable
SearchPage({super.key}); class SearchPage extends HookConsumerWidget {
const SearchPage({super.key});
FocusNode searchFocusNode = FocusNode();
@override
@override Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context, WidgetRef ref) { final curatedLocation = ref.watch(getCuratedLocationProvider);
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled; final curatedPeople = ref.watch(getAllPeopleProvider);
final curatedLocation = ref.watch(getCuratedLocationProvider); final isMapEnabled =
final curatedPeople = ref.watch(getCuratedPeopleProvider); ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map));
final isMapEnabled = double imageSize = math.min(context.width / 3, 150);
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map));
double imageSize = math.min(context.width / 3, 150); TextStyle categoryTitleStyle = const TextStyle(
fontWeight: FontWeight.w500,
TextStyle categoryTitleStyle = const TextStyle( fontSize: 15.0,
fontWeight: FontWeight.w500, );
fontSize: 15.0,
); Color categoryIconColor = context.isDarkTheme ? Colors.white : Colors.black;
Color categoryIconColor = context.isDarkTheme ? Colors.white : Colors.black; showNameEditModel(
String personId,
useEffect( String personName,
() { ) {
searchFocusNode = FocusNode(); return showDialog(
return () => searchFocusNode.dispose(); context: context,
}, builder: (BuildContext context) {
[], return PersonNameEditForm(personId: personId, personName: personName);
); },
);
onSearchSubmitted(String searchTerm) async { }
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch(); buildPeople() {
return SizedBox(
context.pushRoute( height: imageSize,
SearchResultRoute( child: curatedPeople.widgetWhen(
searchTerm: searchTerm, onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
), onData: (people) => Padding(
); padding: const EdgeInsets.only(
} left: 16,
top: 8,
showNameEditModel( ),
String personId, child: CuratedPeopleRow(
String personName, content: people
) { .map((e) => CuratedContent(label: e.name, id: e.id))
return showDialog( .take(12)
context: context, .toList(),
builder: (BuildContext context) { onTap: (content, index) {
return PersonNameEditForm(personId: personId, personName: personName); context.pushRoute(
}, PersonResultRoute(
); personId: content.id,
} personName: content.label,
),
buildPeople() { );
return SizedBox( },
height: imageSize, onNameTap: (person, index) => {
child: curatedPeople.widgetWhen( showNameEditModel(person.id, person.label),
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(), buildPlaces() {
onTap: (content, index) { return SizedBox(
context.pushRoute( height: imageSize,
PersonResultRoute( child: curatedLocation.widgetWhen(
personId: content.id, onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
personName: content.label, onData: (locations) => CuratedPlacesRow(
), isMapEnabled: isMapEnabled,
); content: locations
}, .map(
onNameTap: (person, index) => { (o) => CuratedContent(
showNameEditModel(person.id, person.label), id: o.id,
}, label: o.city,
), ),
), )
), .toList(),
); imageSize: imageSize,
} onTap: (content, index) {
context.pushRoute(
buildPlaces() { SearchInputRoute(
return SizedBox( prefilter: SearchFilter(
height: imageSize, people: {},
child: curatedLocation.widgetWhen( location: SearchLocationFilter(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), city: content.label,
onData: (locations) => CuratedPlacesRow( ),
isMapEnabled: isMapEnabled, camera: SearchCameraFilter(),
content: locations date: SearchDateFilter(),
.map( display: SearchDisplayFilters(
(o) => CuratedContent( isNotInAlbum: false,
id: o.id, isArchive: false,
label: o.city, isFavorite: false,
), ),
) mediaType: AssetType.other,
.toList(), ),
imageSize: imageSize, ),
onTap: (content, index) { );
context.pushRoute( },
SearchResultRoute( ),
searchTerm: 'm:${content.label}', ),
), );
); }
},
), buildSearchButton() {
), return GestureDetector(
); onTap: () {
} context.pushRoute(SearchInputRoute());
},
return Scaffold( child: Card(
appBar: ImmichSearchBar( elevation: 0,
searchFocusNode: searchFocusNode, shape: RoundedRectangleBorder(
onSubmitted: onSearchSubmitted, borderRadius: BorderRadius.circular(20),
), side: BorderSide(
body: GestureDetector( color: context.isDarkTheme
onTap: () { ? Colors.grey[800]!
searchFocusNode.unfocus(); : const Color.fromARGB(255, 225, 225, 225),
ref.watch(searchPageStateProvider.notifier).disableSearch(); ),
}, ),
child: Stack( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [ child: Padding(
ListView( padding: const EdgeInsets.symmetric(
children: [ horizontal: 16.0,
SearchRowTitle( vertical: 12.0,
title: "search_page_people".tr(), ),
onViewAllPressed: () => child: Row(
context.pushRoute(const AllPeopleRoute()), children: [
), Icon(Icons.search, color: context.primaryColor),
buildPeople(), const SizedBox(width: 16.0),
SearchRowTitle( Text(
title: "search_page_places".tr(), "Search your photos",
onViewAllPressed: () => style: context.textTheme.bodyLarge?.copyWith(
context.pushRoute(const CuratedLocationRoute()), color:
top: 0, context.isDarkTheme ? Colors.white70 : Colors.black54,
), fontWeight: FontWeight.w400,
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,
), return Scaffold(
).tr(), appBar: const ImmichAppBar(),
), body: Stack(
ListTile( children: [
leading: Icon( ListView(
Icons.favorite_border_rounded, children: [
color: categoryIconColor, buildSearchButton(),
), SearchRowTitle(
title: title: "search_page_people".tr(),
Text('search_page_favorites', style: categoryTitleStyle) onViewAllPressed: () =>
.tr(), context.pushRoute(const AllPeopleRoute()),
onTap: () => context.pushRoute(const FavoritesRoute()), ),
), buildPeople(),
const CategoryDivider(), SearchRowTitle(
ListTile( title: "search_page_places".tr(),
leading: Icon( onViewAllPressed: () =>
Icons.schedule_outlined, context.pushRoute(const CuratedLocationRoute()),
color: categoryIconColor, top: 0,
), ),
title: Text( const SizedBox(height: 10.0),
'search_page_recently_added', buildPlaces(),
style: categoryTitleStyle, const SizedBox(height: 24.0),
).tr(), Padding(
onTap: () => context.pushRoute(const RecentlyAddedRoute()), padding: const EdgeInsets.symmetric(horizontal: 16),
), child: Text(
const SizedBox(height: 24.0), 'search_page_your_activity',
Padding( style: context.textTheme.bodyLarge?.copyWith(
padding: const EdgeInsets.symmetric(horizontal: 16.0), fontWeight: FontWeight.w500,
child: Text( ),
'search_page_categories', ).tr(),
style: context.textTheme.bodyLarge?.copyWith( ),
fontWeight: FontWeight.w500, ListTile(
), leading: Icon(
).tr(), Icons.favorite_border_rounded,
), color: categoryIconColor,
ListTile( ),
title: title: Text('search_page_favorites', style: categoryTitleStyle)
Text('search_page_screenshots', style: categoryTitleStyle) .tr(),
.tr(), onTap: () => context.pushRoute(const FavoritesRoute()),
leading: Icon( ),
Icons.screenshot, const CategoryDivider(),
color: categoryIconColor, ListTile(
), leading: Icon(
onTap: () => context.pushRoute( Icons.schedule_outlined,
SearchResultRoute( color: categoryIconColor,
searchTerm: 'screenshots', ),
), title: Text(
), 'search_page_recently_added',
), style: categoryTitleStyle,
const CategoryDivider(), ).tr(),
ListTile( onTap: () => context.pushRoute(const RecentlyAddedRoute()),
title: Text('search_page_selfies', style: categoryTitleStyle) ),
.tr(), const SizedBox(height: 24.0),
leading: Icon( Padding(
Icons.photo_camera_front_outlined, padding: const EdgeInsets.symmetric(horizontal: 16.0),
color: categoryIconColor, child: Text(
), 'search_page_categories',
onTap: () => context.pushRoute( style: context.textTheme.bodyLarge?.copyWith(
SearchResultRoute( fontWeight: FontWeight.w500,
searchTerm: 'selfies', ),
), ).tr(),
), ),
), ListTile(
const CategoryDivider(), title:
ListTile( Text('search_page_videos', style: categoryTitleStyle).tr(),
title: Text('search_page_videos', style: categoryTitleStyle) leading: Icon(
.tr(), Icons.play_circle_outline,
leading: Icon( color: categoryIconColor,
Icons.play_circle_outline, ),
color: categoryIconColor, onTap: () => context.pushRoute(const AllVideosRoute()),
), ),
onTap: () => context.pushRoute(const AllVideosRoute()), const CategoryDivider(),
), ListTile(
const CategoryDivider(), title: Text(
ListTile( 'search_page_motion_photos',
title: Text( style: categoryTitleStyle,
'search_page_motion_photos', ).tr(),
style: categoryTitleStyle, leading: Icon(
).tr(), Icons.motion_photos_on_outlined,
leading: Icon( color: categoryIconColor,
Icons.motion_photos_on_outlined, ),
color: categoryIconColor, onTap: () => context.pushRoute(const AllMotionPhotosRoute()),
), ),
onTap: () => context.pushRoute(const AllMotionPhotosRoute()), ],
), ),
], ],
), ),
if (isSearchEnabled) );
SearchSuggestionList(onSubmitted: onSearchSubmitted), }
], }
),
), class CategoryDivider extends StatelessWidget {
); const CategoryDivider({super.key});
}
} @override
Widget build(BuildContext context) {
class CategoryDivider extends StatelessWidget { return const Padding(
const CategoryDivider({super.key}); padding: EdgeInsets.only(
left: 56,
@override right: 16,
Widget build(BuildContext context) { ),
return const Padding( child: Divider(
padding: EdgeInsets.only( height: 0,
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/login/views/login_page.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.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/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/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/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_edit_page.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_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/person_result_page.dart';
import 'package:immich_mobile/modules/search/views/recently_added_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_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/modules/settings/views/settings_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/routing/custom_transition_builders.dart'; import 'package:immich_mobile/routing/custom_transition_builders.dart';
@ -125,10 +126,6 @@ class AppRouter extends _$AppRouter {
page: BackupControllerRoute.page, page: BackupControllerRoute.page,
guards: [_authGuard, _duplicateGuard, _backupPermissionGuard], guards: [_authGuard, _duplicateGuard, _backupPermissionGuard],
), ),
AutoRoute(
page: SearchResultRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute( AutoRoute(
page: CuratedLocationRoute.page, page: CuratedLocationRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
@ -223,6 +220,11 @@ class AppRouter extends _$AppRouter {
page: BackupOptionsRoute.page, page: BackupOptionsRoute.page,
guards: [_authGuard, _duplicateGuard], 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(), child: const RecentlyAddedPage(),
); );
}, },
SearchRoute.name: (routeData) { SearchInputRoute.name: (routeData) {
final args = routeData.argsAs<SearchRouteArgs>( final args = routeData.argsAs<SearchInputRouteArgs>(
orElse: () => const SearchRouteArgs()); orElse: () => const SearchInputRouteArgs());
return AutoRoutePage<dynamic>( return AutoRoutePage<dynamic>(
routeData: routeData, routeData: routeData,
child: SearchPage(key: args.key), child: SearchInputPage(
key: args.key,
prefilter: args.prefilter,
),
); );
}, },
SearchResultRoute.name: (routeData) { SearchRoute.name: (routeData) {
final args = routeData.argsAs<SearchResultRouteArgs>();
return AutoRoutePage<dynamic>( return AutoRoutePage<dynamic>(
routeData: routeData, routeData: routeData,
child: SearchResultPage( child: const SearchPage(),
key: args.key,
searchTerm: args.searchTerm,
),
); );
}, },
SelectAdditionalUserForSharingRoute.name: (routeData) { SelectAdditionalUserForSharingRoute.name: (routeData) {
@ -1113,69 +1112,55 @@ class RecentlyAddedRoute extends PageRouteInfo<void> {
} }
/// generated route for /// generated route for
/// [SearchPage] /// [SearchInputPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> { class SearchInputRoute extends PageRouteInfo<SearchInputRouteArgs> {
SearchRoute({ SearchInputRoute({
Key? key, Key? key,
SearchFilter? prefilter,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : 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, SearchRoute.name,
args: SearchRouteArgs(key: key),
initialChildren: children, initialChildren: children,
); );
static const String name = 'SearchRoute'; static const String name = 'SearchRoute';
static const PageInfo<SearchRouteArgs> page = PageInfo<SearchRouteArgs>(name); static const PageInfo<void> page = PageInfo<void>(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}';
}
} }
/// generated route for /// generated route for

View file

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

View file

@ -43,6 +43,7 @@ class MultiselectGrid extends HookConsumerWidget {
this.editEnabled = false, this.editEnabled = false,
this.unarchive = false, this.unarchive = false,
this.unfavorite = false, this.unfavorite = false,
this.emptyIndicator,
}); });
final ProviderListenable<AsyncValue<RenderList>> renderListProvider; final ProviderListenable<AsyncValue<RenderList>> renderListProvider;
@ -57,12 +58,12 @@ class MultiselectGrid extends HookConsumerWidget {
final bool favoriteEnabled; final bool favoriteEnabled;
final bool unfavorite; final bool unfavorite;
final bool editEnabled; final bool editEnabled;
final Widget? emptyIndicator;
Widget buildDefaultLoadingIndicator() => Widget buildDefaultLoadingIndicator() =>
const Center(child: ImmichLoadingIndicator()); const Center(child: ImmichLoadingIndicator());
Widget buildEmptyIndicator() => Widget buildEmptyIndicator() =>
const Center(child: Text("No assets to show")); emptyIndicator ?? const Center(child: Text("No assets to show"));
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.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/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/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/tab.provider.dart'; import 'package:immich_mobile/shared/providers/tab.provider.dart';
@ -53,10 +52,6 @@ class TabControllerPage extends HookConsumerWidget {
// Scroll to top // Scroll to top
scrollToTopNotifierProvider.scrollToTop(); scrollToTopNotifierProvider.scrollToTop();
} }
if (tabsRouter.activeIndex == 1 && index == 1) {
// Focus search
searchFocusNotifier.requestFocus();
}
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
tabsRouter.setActiveIndex(index); tabsRouter.setActiveIndex(index);
@ -111,10 +106,7 @@ class TabControllerPage extends HookConsumerWidget {
// Scroll to top // Scroll to top
scrollToTopNotifierProvider.scrollToTop(); scrollToTopNotifierProvider.scrollToTop();
} }
if (tabsRouter.activeIndex == 1 && index == 1) {
// Focus search
searchFocusNotifier.requestFocus();
}
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
tabsRouter.setActiveIndex(index); tabsRouter.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index]; ref.read(tabProvider.notifier).state = TabEnum.values[index];
@ -170,11 +162,11 @@ class TabControllerPage extends HookConsumerWidget {
final multiselectEnabled = ref.watch(multiselectProvider); final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter( return AutoTabsRouter(
routes: [ routes: const [
const HomeRoute(), HomeRoute(),
SearchRoute(), SearchRoute(),
const SharingRoute(), SharingRoute(),
const LibraryRoute(), LibraryRoute(),
], ],
duration: const Duration(milliseconds: 600), duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition( transitionBuilder: (context, child, animation) => FadeTransition(

View file

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

File diff suppressed because it is too large Load diff