1
0
forked from Cutlery/immich

merge main

This commit is contained in:
Alex 2024-04-01 10:52:16 -05:00
commit 12c5b5fcb7
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
43 changed files with 2674 additions and 1072 deletions

View File

@ -1,4 +1,4 @@
# Preparing a pre-existing Postgres server # Pre-existing Postgres
While not officially recommended, it is possible to run Immich using a pre-existing Postgres server. To use this setup, you should have a baseline level of familiarity with Postgres and the Linux command line. If you do not have these, we recommend using the default setup with a dedicated Postgres container. While not officially recommended, it is possible to run Immich using a pre-existing Postgres server. To use this setup, you should have a baseline level of familiarity with Postgres and the Linux command line. If you do not have these, we recommend using the default setup with a dedicated Postgres container.
@ -45,7 +45,7 @@ CREATE EXTENSION vectors;
CREATE EXTENSION earthdistance CASCADE; CREATE EXTENSION earthdistance CASCADE;
ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors; ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors;
GRANT USAGE ON SCHEMA vectors TO <immichdbusername>; GRANT USAGE ON SCHEMA vectors TO <immichdbusername>;
GRANT SELECT ON TABLE pg_vector_index_stat to <immichdbusername>; ALTER DEFAULT PRIVILEGES IN SCHEMA vectors GRANT SELECT ON TABLES TO <immichdbusername>;
COMMIT; COMMIT;
``` ```

View File

@ -27,8 +27,8 @@ The metrics in immich are grouped into API (endpoint calls and response times),
Immich will not expose an endpoint for metrics by default. To enable this endpoint, you can add the `IMMICH_METRICS=true` environmental variable to your `.env` file. Note that only the server and microservices containers currently use this variable. Immich will not expose an endpoint for metrics by default. To enable this endpoint, you can add the `IMMICH_METRICS=true` environmental variable to your `.env` file. Note that only the server and microservices containers currently use this variable.
:::note :::tip
`IMMICH_METRICS` is equivalent to enabling the following three environmental variables: `IMMICH_API_METRICS`, `IMMICH_HOST_METRICS`, and `IMMICH_IO_METRICS`. If you would like to only expose certain kinds of metrics, you can set only those environmental variables to `true`. Explicitly setting the environmental variable for a metric group overrides `IMMICH_METRICS` for that group. `IMMICH_METRICS` enables all metrics, but there are also [environmental variables](/docs/install/environment-variables.md#prometheus) to toggle specific metric groups. If you'd like to only expose certain kinds of metrics, you can set only those environmental variables to `true`. Explicitly setting the environmental variable for a metric group overrides `IMMICH_METRICS` for that group. For example, setting `IMMICH_METRICS=true` and `IMMICH_API_METRICS=false` will enable all metrics except API metrics.
::: :::
The next step is to configure a new or existing Prometheus instance to scrape this endpoint. The following steps assume that you do not have an existing Prometheus instance, but the steps will be similar either way. The next step is to configure a new or existing Prometheus instance to scrape this endpoint. The following steps assume that you do not have an existing Prometheus instance, but the steps will be similar either way.

View File

@ -41,11 +41,9 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_REVERSE_GEOCODING_ROOT` | Path of reverse geocoding dump directory | `/usr/src/resources` | microservices | | `IMMICH_REVERSE_GEOCODING_ROOT` | Path of reverse geocoding dump directory | `/usr/src/resources` | microservices |
:::tip :::tip
`TZ` should be set to a `TZ identifier` from [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). For example, `TZ="Etc/UTC"`.
`TZ` is only used by the `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. `TZ` is only used by `exiftool`, which is present in the microservices container, as a fallback in case the timezone cannot be determined from the image metadata.
`exiftool` is only present in the microservices container.
::: :::
## Ports ## Ports
@ -147,6 +145,18 @@ Other machine learning parameters can be tuned from the admin UI.
::: :::
## Prometheus
| Variable | Description | Default | Services |
| :----------------------------- | :-------------------------------------------------------------------------------------------- | :-----: | :-------------------- |
| `IMMICH_METRICS`<sup>\*1</sup> | Toggle all metrics (one of [`true`, `false`]) | | server, microservices |
| `IMMICH_API_METRICS` | Toggle metrics for endpoints and response times (one of [`true`, `false`]) | | server, microservices |
| `IMMICH_HOST_METRICS` | Toggle metrics for CPU and memory utilization for host and process (one of [`true`, `false`]) | | server, microservices |
| `IMMICH_IO_METRICS` | Toggle metrics for database queries, image processing, etc. (one of [`true`, `false`]) | | server, microservices |
| `IMMICH_JOB_METRICS` | Toggle metrics for jobs and queues (one of [`true`, `false`]) | | server, microservices |
\*1: Overridden for a metric group when its corresponding environmental variable is set.
## Docker Secrets ## Docker Secrets
The following variables support the use of [Docker secrets](https://docs.docker.com/engine/swarm/secrets/) for additional security. The following variables support the use of [Docker secrets](https://docs.docker.com/engine/swarm/secrets/) for additional security.

View File

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

View File

@ -243,12 +243,7 @@ class AlbumService {
} }
} }
await _db.writeTxn(() async { await _updateAssets(album.id, add: successAssets);
await album.assets.update(link: successAssets);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.albums.put(a!);
});
return AddAssetsResponse( return AddAssetsResponse(
alreadyInAlbum: duplicatedAssets, alreadyInAlbum: duplicatedAssets,
@ -257,11 +252,28 @@ class AlbumService {
} }
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
return null;
} }
return null; return null;
} }
Future<void> _updateAssets(
int albumId, {
Iterable<Asset> add = const [],
Iterable<Asset> remove = const [],
}) {
return _db.writeTxn(() async {
final album = await _db.albums.get(albumId);
if (album == null) return;
await album.assets.update(link: add, unlink: remove);
album.startDate =
await album.assets.filter().fileCreatedAtProperty().min();
album.endDate = await album.assets.filter().fileCreatedAtProperty().max();
album.lastModifiedAssetTimestamp =
await album.assets.filter().updatedAtProperty().max();
await _db.albums.put(album);
});
}
Future<bool> addAdditionalUserToAlbum( Future<bool> addAdditionalUserToAlbum(
List<String> sharedUserIds, List<String> sharedUserIds,
Album album, Album album,
@ -342,7 +354,7 @@ class AlbumService {
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me"); await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}"); debugPrint("Error leaveAlbum ${e.toString()}");
return false; return false;
} }
} }
@ -352,24 +364,25 @@ class AlbumService {
Iterable<Asset> assets, Iterable<Asset> assets,
) async { ) async {
try { try {
await _apiService.albumApi.removeAssetFromAlbum( final response = await _apiService.albumApi.removeAssetFromAlbum(
album.remoteId!, album.remoteId!,
BulkIdsDto( BulkIdsDto(
ids: assets.map((asset) => asset.remoteId!).toList(), ids: assets.map((asset) => asset.remoteId!).toList(),
), ),
); );
await _db.writeTxn(() async { if (response != null) {
await album.assets.update(unlink: assets); final toRemove = response.every((e) => e.success)
final a = await _db.albums.get(album.id); ? assets
// trigger watcher : response
await _db.albums.put(a!); .where((e) => e.success)
}); .map((e) => assets.firstWhere((a) => a.remoteId == e.id));
await _updateAssets(album.id, remove: toRemove);
return true; return true;
}
} catch (e) { } catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}"); debugPrint("Error removeAssetFromAlbum ${e.toString()}");
return false;
} }
return false;
} }
Future<bool> removeUserFromAlbum( Future<bool> removeUserFromAlbum(
@ -413,7 +426,7 @@ class AlbumService {
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}"); debugPrint("Error changeTitleAlbum ${e.toString()}");
return false; return false;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'paginated_search.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$paginatedSearchRenderListHash() =>
r'c2cc2381ee6ea8f8e08d6d4c1289bbf0c6b9647e';
/// See also [paginatedSearchRenderList].
@ProviderFor(paginatedSearchRenderList)
final paginatedSearchRenderListProvider =
AutoDisposeProvider<AsyncValue<RenderList>>.internal(
paginatedSearchRenderList,
name: r'paginatedSearchRenderListProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$paginatedSearchRenderListHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PaginatedSearchRenderListRef
= AutoDisposeProviderRef<AsyncValue<RenderList>>;
String _$paginatedSearchHash() => r'8312f358261368cf2b5572b839fdd8f8fbe9a62e';
/// See also [PaginatedSearch].
@ProviderFor(PaginatedSearch)
final paginatedSearchProvider =
AutoDisposeAsyncNotifierProvider<PaginatedSearch, List<Asset>>.internal(
PaginatedSearch.new,
name: r'paginatedSearchProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$paginatedSearchHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$PaginatedSearch = AutoDisposeAsyncNotifier<List<Asset>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

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

View File

@ -6,23 +6,21 @@ part of 'people.provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$getCuratedPeopleHash() => r'2a534553812abe69abce2c2e41aa62b8de16e9d0'; String _$getAllPeopleHash() => r'4eff6666be5a74710d1e8587e01d8154310d85bd';
/// See also [getCuratedPeople]. /// See also [getAllPeople].
@ProviderFor(getCuratedPeople) @ProviderFor(getAllPeople)
final getCuratedPeopleProvider = final getAllPeopleProvider =
AutoDisposeFutureProvider<List<CuratedContent>>.internal( AutoDisposeFutureProvider<List<PersonResponseDto>>.internal(
getCuratedPeople, getAllPeople,
name: r'getCuratedPeopleProvider', name: r'getAllPeopleProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') debugGetCreateSourceHash:
? null const bool.fromEnvironment('dart.vm.product') ? null : _$getAllPeopleHash,
: _$getCuratedPeopleHash,
dependencies: null, dependencies: null,
allTransitiveDependencies: null, allTransitiveDependencies: null,
); );
typedef GetCuratedPeopleRef typedef GetAllPeopleRef = AutoDisposeFutureProviderRef<List<PersonResponseDto>>;
= AutoDisposeFutureProviderRef<List<CuratedContent>>;
String _$personAssetsHash() => r'1d6eff5ca3aa630b58c4dad9516193b21896984d'; String _$personAssetsHash() => r'1d6eff5ca3aa630b58c4dad9516193b21896984d';
/// Copied from Dart SDK /// Copied from Dart SDK
@ -172,7 +170,7 @@ class _PersonAssetsProviderElement
String get personId => (origin as PersonAssetsProvider).personId; String get personId => (origin as PersonAssetsProvider).personId;
} }
String _$updatePersonNameHash() => r'c7179a7cc558669c3b30b03fbca7782a42f2b6fd'; String _$updatePersonNameHash() => r'7145aaaf6fc38fdafe3a283ebf3d3f4fd0774cd2';
/// See also [updatePersonName]. /// See also [updatePersonName].
@ProviderFor(updatePersonName) @ProviderFor(updatePersonName)

View File

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

View File

@ -0,0 +1,229 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_filter.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$getSearchSuggestionsHash() =>
r'bc1e9a1a060868f14e6eb970d2251dbfe39c6866';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [getSearchSuggestions].
@ProviderFor(getSearchSuggestions)
const getSearchSuggestionsProvider = GetSearchSuggestionsFamily();
/// See also [getSearchSuggestions].
class GetSearchSuggestionsFamily extends Family<AsyncValue<List<String>>> {
/// See also [getSearchSuggestions].
const GetSearchSuggestionsFamily();
/// See also [getSearchSuggestions].
GetSearchSuggestionsProvider call(
SearchSuggestionType type, {
String? locationCountry,
String? locationState,
String? make,
String? model,
}) {
return GetSearchSuggestionsProvider(
type,
locationCountry: locationCountry,
locationState: locationState,
make: make,
model: model,
);
}
@override
GetSearchSuggestionsProvider getProviderOverride(
covariant GetSearchSuggestionsProvider provider,
) {
return call(
provider.type,
locationCountry: provider.locationCountry,
locationState: provider.locationState,
make: provider.make,
model: provider.model,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'getSearchSuggestionsProvider';
}
/// See also [getSearchSuggestions].
class GetSearchSuggestionsProvider
extends AutoDisposeFutureProvider<List<String>> {
/// See also [getSearchSuggestions].
GetSearchSuggestionsProvider(
SearchSuggestionType type, {
String? locationCountry,
String? locationState,
String? make,
String? model,
}) : this._internal(
(ref) => getSearchSuggestions(
ref as GetSearchSuggestionsRef,
type,
locationCountry: locationCountry,
locationState: locationState,
make: make,
model: model,
),
from: getSearchSuggestionsProvider,
name: r'getSearchSuggestionsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$getSearchSuggestionsHash,
dependencies: GetSearchSuggestionsFamily._dependencies,
allTransitiveDependencies:
GetSearchSuggestionsFamily._allTransitiveDependencies,
type: type,
locationCountry: locationCountry,
locationState: locationState,
make: make,
model: model,
);
GetSearchSuggestionsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.type,
required this.locationCountry,
required this.locationState,
required this.make,
required this.model,
}) : super.internal();
final SearchSuggestionType type;
final String? locationCountry;
final String? locationState;
final String? make;
final String? model;
@override
Override overrideWith(
FutureOr<List<String>> Function(GetSearchSuggestionsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: GetSearchSuggestionsProvider._internal(
(ref) => create(ref as GetSearchSuggestionsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
type: type,
locationCountry: locationCountry,
locationState: locationState,
make: make,
model: model,
),
);
}
@override
AutoDisposeFutureProviderElement<List<String>> createElement() {
return _GetSearchSuggestionsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is GetSearchSuggestionsProvider &&
other.type == type &&
other.locationCountry == locationCountry &&
other.locationState == locationState &&
other.make == make &&
other.model == model;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, type.hashCode);
hash = _SystemHash.combine(hash, locationCountry.hashCode);
hash = _SystemHash.combine(hash, locationState.hashCode);
hash = _SystemHash.combine(hash, make.hashCode);
hash = _SystemHash.combine(hash, model.hashCode);
return _SystemHash.finish(hash);
}
}
mixin GetSearchSuggestionsRef on AutoDisposeFutureProviderRef<List<String>> {
/// The parameter `type` of this provider.
SearchSuggestionType get type;
/// The parameter `locationCountry` of this provider.
String? get locationCountry;
/// The parameter `locationState` of this provider.
String? get locationState;
/// The parameter `make` of this provider.
String? get make;
/// The parameter `model` of this provider.
String? get model;
}
class _GetSearchSuggestionsProviderElement
extends AutoDisposeFutureProviderElement<List<String>>
with GetSearchSuggestionsRef {
_GetSearchSuggestionsProviderElement(super.provider);
@override
SearchSuggestionType get type =>
(origin as GetSearchSuggestionsProvider).type;
@override
String? get locationCountry =>
(origin as GetSearchSuggestionsProvider).locationCountry;
@override
String? get locationState =>
(origin as GetSearchSuggestionsProvider).locationState;
@override
String? get make => (origin as GetSearchSuggestionsProvider).make;
@override
String? get model => (origin as GetSearchSuggestionsProvider).model;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,10 +61,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: auto_route name: auto_route
sha256: "82f8df1d177416bc6b7a449127d0270ff1f0f633a91f2ceb7a85d4f07c3affa1" sha256: eb33554581a0a4aa7e6da0f13a44291a55bf71359012f1d9feb41634ff908ff8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.8.4" version: "7.9.2"
auto_route_generator: auto_route_generator:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -117,10 +117,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: build_runner name: build_runner
sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.8" version: "2.4.9"
build_runner_core: build_runner_core:
dependency: transitive dependency: transitive
description: description:
@ -141,10 +141,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: built_value name: built_value
sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.9.0" version: "8.9.1"
cached_network_image: cached_network_image:
dependency: "direct main" dependency: "direct main"
description: description:
@ -309,34 +309,34 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: custom_lint name: custom_lint
sha256: f89ff83efdba7c8996e86bb3bad0b759d58f9b19ae4d0e277a386ddd8b481217 sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" version: "0.6.4"
custom_lint_builder: custom_lint_builder:
dependency: transitive dependency: transitive
description: description:
name: custom_lint_builder name: custom_lint_builder
sha256: "3a14687fc71a5e2124a29722106f7b7e67dd5a6d58e33f2859650b46acff1d54" sha256: d7dc41e709dde223806660268678be7993559e523eb3164e2a1425fd6f7615a9
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.1" version: "0.6.4"
custom_lint_core: custom_lint_core:
dependency: transitive dependency: transitive
description: description:
name: custom_lint_core name: custom_lint_core
sha256: "1e9128e095ad5e0973469bdaac1ead8bfc86c485954c23cf617299de5e6fa029" sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.1" version: "0.6.3"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.4" version: "2.3.6"
dartx: dartx:
dependency: transitive dependency: transitive
description: description:
@ -381,10 +381,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: easy_localization name: easy_localization
sha256: "9c86754b22aaa3e74e471635b25b33729f958dd6fb83df0ad6612948a7b231af" sha256: c145aeb6584aedc7c862ab8c737c3277788f47488bfdf9bae0fe112bd0a4789c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.4" version: "3.0.5"
easy_logger: easy_logger:
dependency: transitive dependency: transitive
description: description:
@ -405,10 +405,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.2"
file: file:
dependency: transitive dependency: transitive
description: description:
@ -503,18 +503,18 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_lints name: flutter_lints
sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
flutter_local_notifications: flutter_local_notifications:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: c18f1de98fe0bb9dd5ba91e1330d4febc8b6a7de6aae3ffe475ef423723e72f3 sha256: "55b9b229307a10974b26296ff29f2e132256ba4bd74266939118eaefa941cb00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "16.3.2" version: "16.3.3"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
@ -540,10 +540,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_native_splash name: flutter_native_splash
sha256: "558f10070f03ee71f850a78f7136ab239a67636a294a44a06b6b7345178edb1e" sha256: edf39bcf4d74aca1eb2c1e43c3e445fd9f494013df7f0da752fefe72020eedc0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.10" version: "2.4.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -556,10 +556,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_riverpod name: flutter_riverpod
sha256: "4bce556b7ecbfea26109638d5237684538d4abc509d253e6c5c4c5733b360098" sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.10" version: "2.5.1"
flutter_svg: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@ -614,10 +614,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: frontend_server_client name: frontend_server_client
sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.0" version: "4.0.0"
fuchsia_remote_debug_protocol: fuchsia_remote_debug_protocol:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -635,18 +635,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_android name: geolocator_android
sha256: "136f1c97e1903366393bda514c5d9e98843418baea52899aa45edae9af8a5cd6" sha256: f15d1536cd01b1399578f1da1eb5d566e7a718db6a3648f2c24d2e2f859f0692
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.2" version: "4.5.4"
geolocator_apple: geolocator_apple:
dependency: transitive dependency: transitive
description: description:
name: geolocator_apple name: geolocator_apple
sha256: "2f2d4ee16c4df269e93c0e382be075cc01d5db6703c3196e4af20a634fe49ef4" sha256: bc2aca02423ad429cb0556121f56e60360a2b7d694c8570301d06ea0c00732fd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.6" version: "2.3.7"
geolocator_platform_interface: geolocator_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -667,10 +667,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_windows name: geolocator_windows
sha256: a92fae29779d5c6dc60e8411302f5221ade464968fe80a36d330e80a71f087af sha256: "53da08937d07c24b0d9952eb57a3b474e29aae2abf9dd717f7e1230995f13f0e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.2" version: "0.2.3"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@ -691,10 +691,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: hooks_riverpod name: hooks_riverpod
sha256: "758b07eba336e3cbacbd81dba481f2228a14102083fdde07045e8514e8054c49" sha256: "45b2030a18bcd6dbd680c2c91bc3b33e3fe7c323e3acb5ecec93a613e2fbaa8a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.10" version: "2.5.1"
hotreloader: hotreloader:
dependency: transitive dependency: transitive
description: description:
@ -755,10 +755,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: "39f2bfe497e495450c81abcd44b62f56c2a36a37a175da7d137b4454977b51b1" sha256: "42c098e7fb6334746be37cdc30369ade356ed4f14d48b7a0313f95a9159f4321"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.9+3" version: "0.8.9+5"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@ -771,10 +771,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_ios name: image_picker_ios
sha256: fadafce49e8569257a0cad56d24438a6fa1f0cbd7ee0af9b631f7492818a4ca3 sha256: "917a5cadd67d052554cfb258595e54217de53fac5b52939426e26319a02e6297"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.9+1" version: "0.8.9+2"
image_picker_linux: image_picker_linux:
dependency: transitive dependency: transitive
description: description:
@ -795,10 +795,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_platform_interface name: image_picker_platform_interface
sha256: fa4e815e6fcada50e35718727d83ba1c92f1edf95c0b4436554cec301b56233b sha256: "3d2c323daea9d60608f1caf30be32a938916f4975434b8352e6f73dae496da38"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.9.3" version: "2.9.4"
image_picker_windows: image_picker_windows:
dependency: transitive dependency: transitive
description: description:
@ -922,7 +922,7 @@ packages:
description: description:
path: maplibre_gl_platform_interface path: maplibre_gl_platform_interface
ref: main ref: main
resolved-ref: "3cf0abb051849ca3f14e6aa19d2261ad18f22ce1" resolved-ref: ec5a29dea08e8c2fadf9c55bd5bc500ef5b2a685
url: "https://github.com/maplibre/flutter-maplibre-gl.git" url: "https://github.com/maplibre/flutter-maplibre-gl.git"
source: git source: git
version: "0.18.0" version: "0.18.0"
@ -931,7 +931,7 @@ packages:
description: description:
path: maplibre_gl_web path: maplibre_gl_web
ref: main ref: main
resolved-ref: "3cf0abb051849ca3f14e6aa19d2261ad18f22ce1" resolved-ref: ec5a29dea08e8c2fadf9c55bd5bc500ef5b2a685
url: "https://github.com/maplibre/flutter-maplibre-gl.git" url: "https://github.com/maplibre/flutter-maplibre-gl.git"
source: git source: git
version: "0.18.0" version: "0.18.0"
@ -1106,10 +1106,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: permission_handler name: permission_handler
sha256: "74e962b7fad7ff75959161bb2c0ad8fe7f2568ee82621c9c2660b751146bfe44" sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.3.0" version: "11.3.1"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
@ -1122,10 +1122,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_apple name: permission_handler_apple
sha256: bdafc6db74253abb63907f4e357302e6bb786ab41465e8635f362ee71fd8707b sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.4.0" version: "9.4.4"
permission_handler_html: permission_handler_html:
dependency: transitive dependency: transitive
description: description:
@ -1138,10 +1138,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_platform_interface name: permission_handler_platform_interface
sha256: "23dfba8447c076ab5be3dee9ceb66aad345c4a648f0cac292c77b1eb0e800b78" sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "4.2.1"
permission_handler_windows: permission_handler_windows:
dependency: transitive dependency: transitive
description: description:
@ -1162,18 +1162,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: photo_manager name: photo_manager
sha256: "8cf79918f6de9843b394a1670fe1aec54ebcac852b4b4c9ef88211894547dc61" sha256: df594f989f0c31cdb3ed48f3d49cb9ffadf11cc3700d2c3460b1912c93432621
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0-dev.5" version: "3.0.0"
photo_manager_image_provider: photo_manager_image_provider:
dependency: "direct main" dependency: "direct main"
description: description:
name: photo_manager_image_provider name: photo_manager_image_provider
sha256: c187f60c3fdbe5630735d9a0bccbb071397ec03dcb1ba6085c29c8adece798a0 sha256: "38ef1023dc11de3a8669f16e7c981673b3c5cfee715d17120f4b87daa2cdd0af"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.1"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -1218,10 +1218,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: provider name: provider
sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.1" version: "6.1.2"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -1242,10 +1242,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: riverpod name: riverpod
sha256: "548e2192eb7aeb826eb89387f814edb76594f3363e2c0bb99dd733d795ba3589" sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.0" version: "2.5.1"
riverpod_analyzer_utils: riverpod_analyzer_utils:
dependency: transitive dependency: transitive
description: description:
@ -1258,26 +1258,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: riverpod_annotation name: riverpod_annotation
sha256: "77e5d51afa4fa3e67903fb8746f33d368728d7051a0b6c292bcee60aeba46d95" sha256: e5e796c0eba4030c704e9dae1b834a6541814963292839dcf9638d53eba84f5c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.4" version: "2.3.5"
riverpod_generator: riverpod_generator:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: riverpod_generator name: riverpod_generator
sha256: "359068f04879347ae4edbe66c81cc95f83fa1743806d1a0c86e55dd3c33ebb32" sha256: d451608bf17a372025fc36058863737636625dfdb7e3cbf6142e0dfeb366ab22
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.11" version: "2.4.0"
riverpod_lint: riverpod_lint:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: riverpod_lint name: riverpod_lint
sha256: e9bbd02e9e89e18eecb183bbca556d7b523a0669024da9b8167c08903f442937 sha256: "3c67c14ccd16f0c9d53e35ef70d06cd9d072e2fb14557326886bbde903b230a5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.9" version: "2.3.10"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -1306,10 +1306,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: share_plus_platform_interface name: share_plus_platform_interface
sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.1" version: "3.4.0"
shared_preferences: shared_preferences:
dependency: transitive dependency: transitive
description: description:
@ -1439,10 +1439,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5" sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.3" version: "2.5.4"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -1567,10 +1567,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: url_launcher name: url_launcher
sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.2.4" version: "6.2.5"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
@ -1583,10 +1583,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.2.4" version: "6.2.5"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
@ -1671,10 +1671,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: video_player name: video_player
sha256: fbf28ce8bcfe709ad91b5789166c832cb7a684d14f571a81891858fefb5bb1c2 sha256: afc65f4b8bcb2c188f64a591f84fb471f4f2e19fc607c65fd8d2f8fedb3dec23
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.8.2" version: "2.8.3"
video_player_android: video_player_android:
dependency: transitive dependency: transitive
description: description:
@ -1703,10 +1703,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: video_player_web name: video_player_web
sha256: "34beb3a07d4331a24f7e7b2f75b8e2b103289038e07e65529699a671b6a6e2cb" sha256: "8e9cb7fe94e49490e67bbc15149691792b58a0ade31b32e3f3688d104a0e057b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@ -1727,10 +1727,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: wakelock_plus_platform_interface name: wakelock_plus_platform_interface
sha256: "40fabed5da06caff0796dc638e1f07ee395fb18801fbff3255a2372db2d80385" sha256: "582f2f7aecc7376332d961a0dd1efa9378ce117657e0ade55d9ff72699a55e82"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.2.0"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:
@ -1743,18 +1743,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "0.4.2"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: transitive
description: description:
name: web_socket_channel name: web_socket_channel
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b sha256: "939ab60734a4f8fa95feacb55804fa278de28bdeef38e616dc08e44a84adea23"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.3"
webdriver: webdriver:
dependency: transitive dependency: transitive
description: description:
@ -1767,10 +1767,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.0" version: "5.4.0"
win32_registry: win32_registry:
dependency: transitive dependency: transitive
description: description:
@ -1813,4 +1813,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.3.0 <4.0.0"
flutter: ">=3.16.0" flutter: ">=3.19.0"

View File

@ -0,0 +1,29 @@
import { StorageCore } from 'src/cores/storage.core';
jest.mock('src/constants', () => ({
APP_MEDIA_LOCATION: '/photos',
}));
describe('StorageCore', () => {
describe('isImmichPath', () => {
it('should return true for APP_MEDIA_LOCATION path', () => {
const immichPath = '/photos';
expect(StorageCore.isImmichPath(immichPath)).toBe(true);
});
it('should return true for paths within the APP_MEDIA_LOCATION', () => {
const immichPath = '/photos/new/';
expect(StorageCore.isImmichPath(immichPath)).toBe(true);
});
it('should return false for paths outside the APP_MEDIA_LOCATION and same starts', () => {
const nonImmichPath = '/photos_new';
expect(StorageCore.isImmichPath(nonImmichPath)).toBe(false);
});
it('should return false for paths outside the APP_MEDIA_LOCATION', () => {
const nonImmichPath = '/some/other/path';
expect(StorageCore.isImmichPath(nonImmichPath)).toBe(false);
});
});
});

View File

@ -115,7 +115,13 @@ export class StorageCore {
} }
static isImmichPath(path: string) { static isImmichPath(path: string) {
return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION)); const resolvedPath = resolve(path);
const resolvedAppMediaLocation = resolve(APP_MEDIA_LOCATION);
const normalizedPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
const normalizedAppMediaLocation = resolvedAppMediaLocation.endsWith('/')
? resolvedAppMediaLocation
: resolvedAppMediaLocation + '/';
return normalizedPath.startsWith(normalizedAppMediaLocation);
} }
static isGeneratedAsset(path: string) { static isGeneratedAsset(path: string) {

View File

@ -254,15 +254,15 @@ WHERE
OR f_unaccent ("admin1Name") %>> f_unaccent ($1) OR f_unaccent ("admin1Name") %>> f_unaccent ($1)
OR f_unaccent ("alternateNames") %>> f_unaccent ($1) OR f_unaccent ("alternateNames") %>> f_unaccent ($1)
ORDER BY ORDER BY
COALESCE(f_unaccent (name) <->>> f_unaccent ($1), 0) + COALESCE( COALESCE(f_unaccent (name) <->>> f_unaccent ($1), 0.1) + COALESCE(
f_unaccent ("admin2Name") <->>> f_unaccent ($1), f_unaccent ("admin2Name") <->>> f_unaccent ($1),
0 0.1
) + COALESCE( ) + COALESCE(
f_unaccent ("admin1Name") <->>> f_unaccent ($1), f_unaccent ("admin1Name") <->>> f_unaccent ($1),
0 0.1
) + COALESCE( ) + COALESCE(
f_unaccent ("alternateNames") <->>> f_unaccent ($1), f_unaccent ("alternateNames") <->>> f_unaccent ($1),
0 0.1
) ASC ) ASC
LIMIT LIMIT
20 20

View File

@ -214,10 +214,10 @@ export class SearchRepository implements ISearchRepository {
.orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`) .orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
.orderBy( .orderBy(
` `
COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) + COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0.1) +
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) + COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0.1) +
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) + COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0.1) +
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0) COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0.1)
`, `,
) )
.setParameters({ placeName }) .setParameters({ placeName })