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:
parent
861b72ef04
commit
27be813011
35 changed files with 4018 additions and 2753 deletions
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
310
mobile/lib/modules/search/models/search_filter.dart
Normal file
310
mobile/lib/modules/search/models/search_filter.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
BIN
mobile/lib/modules/search/providers/paginated_search.provider.g.dart
generated
Normal file
BIN
mobile/lib/modules/search/providers/paginated_search.provider.g.dart
generated
Normal file
Binary file not shown.
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
Binary file not shown.
|
@ -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 ?? [];
|
||||||
|
}
|
BIN
mobile/lib/modules/search/providers/search_filter.provider.g.dart
generated
Normal file
BIN
mobile/lib/modules/search/providers/search_filter.provider.g.dart
generated
Normal file
Binary file not shown.
|
@ -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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
|
@ -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 ?? [];
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
120
mobile/lib/modules/search/ui/search_filter/camera_picker.dart
Normal file
120
mobile/lib/modules/search/ui/search_filter/camera_picker.dart
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
166
mobile/lib/modules/search/ui/search_filter/location_picker.dart
Normal file
166
mobile/lib/modules/search/ui/search_filter/location_picker.dart
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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(),
|
||||||
}
|
),
|
||||||
}
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
563
mobile/lib/modules/search/views/search_input_page.dart
Normal file
563
mobile/lib/modules/search/views/search_input_page.dart
Normal 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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',
|
||||||
|
|
3624
mobile/pubspec.lock
3624
mobile/pubspec.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue