From 6a4bc777a2eb2ba2d4570c3b2e1f13cd46b3f2cf Mon Sep 17 00:00:00 2001
From: Pablo Diz <87752439+pablodre@users.noreply.github.com>
Date: Sun, 31 Mar 2024 16:47:03 +0200
Subject: [PATCH 1/7] Fix external library path validation #8319 (#8366)
* Fix isImmichPath
* prettier write
* Fis isImmichPath code comment
* Refactor isImmichPath function based on team suggestions
* Test isImmichPath
* fix: clean comments
* Refactor isImmichPath test based on team suggestions
* Clean code with lintern suggestions
---
server/src/cores/storage.core.spec.ts | 29 +++++++++++++++++++++++++++
server/src/cores/storage.core.ts | 8 +++++++-
2 files changed, 36 insertions(+), 1 deletion(-)
create mode 100644 server/src/cores/storage.core.spec.ts
diff --git a/server/src/cores/storage.core.spec.ts b/server/src/cores/storage.core.spec.ts
new file mode 100644
index 000000000..16258f095
--- /dev/null
+++ b/server/src/cores/storage.core.spec.ts
@@ -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);
+ });
+ });
+});
diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts
index b9dad8642..ee9f12e51 100644
--- a/server/src/cores/storage.core.ts
+++ b/server/src/cores/storage.core.ts
@@ -115,7 +115,13 @@ export class StorageCore {
}
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) {
From 5bc9158724308bbd4bcf0f448c2ae845c0a05653 Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Sun, 31 Mar 2024 10:59:11 -0400
Subject: [PATCH 2/7] fix(server): penalize null geodata fields when searching
places (#8408)
---
server/src/queries/search.repository.sql | 8 ++++----
server/src/repositories/search.repository.ts | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql
index c7b660068..e985a1a6d 100644
--- a/server/src/queries/search.repository.sql
+++ b/server/src/queries/search.repository.sql
@@ -254,15 +254,15 @@ WHERE
OR f_unaccent ("admin1Name") %>> f_unaccent ($1)
OR f_unaccent ("alternateNames") %>> f_unaccent ($1)
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),
- 0
+ 0.1
) + COALESCE(
f_unaccent ("admin1Name") <->>> f_unaccent ($1),
- 0
+ 0.1
) + COALESCE(
f_unaccent ("alternateNames") <->>> f_unaccent ($1),
- 0
+ 0.1
) ASC
LIMIT
20
diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts
index 2de48b741..4530d2295 100644
--- a/server/src/repositories/search.repository.ts
+++ b/server/src/repositories/search.repository.ts
@@ -214,10 +214,10 @@ export class SearchRepository implements ISearchRepository {
.orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
.orderBy(
`
- COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
- COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
- COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
- COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
+ COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0.1) +
+ COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0.1) +
+ COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0.1) +
+ COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0.1)
`,
)
.setParameters({ placeName })
From 245535ee0413e6ee4793d092f39effc7f291d4b5 Mon Sep 17 00:00:00 2001
From: mmomjian <50788000+mmomjian@users.noreply.github.com>
Date: Sun, 31 Mar 2024 12:38:16 -0400
Subject: [PATCH 3/7] docs: specify Timezone (#8403)
---
docs/docs/install/environment-variables.md | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index 9fc1b20d2..ea9608c56 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -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 |
:::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.
-
-`exiftool` is only present in the microservices container.
-
+`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.
:::
## Ports
From 169d9d18b08cd2f5117a29f82c9a81a5e528159e Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Sun, 31 Mar 2024 13:29:11 -0400
Subject: [PATCH 4/7] docs: document metric env variables, add job metric env
(#8406)
* update env docs
* show options
---
docs/docs/features/monitoring.md | 4 ++--
docs/docs/install/environment-variables.md | 12 ++++++++++++
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/docs/docs/features/monitoring.md b/docs/docs/features/monitoring.md
index 7e001c992..0ea1382c8 100644
--- a/docs/docs/features/monitoring.md
+++ b/docs/docs/features/monitoring.md
@@ -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.
-:::note
-`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.
+:::tip
+`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.
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index ea9608c56..9da1f3ce9 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -145,6 +145,18 @@ Other machine learning parameters can be tuned from the admin UI.
:::
+## Prometheus
+
+| Variable | Description | Default | Services |
+| :----------------------------- | :-------------------------------------------------------------------------------------------- | :-----: | :-------------------- |
+| `IMMICH_METRICS`\*1 | 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
The following variables support the use of [Docker secrets](https://docs.docker.com/engine/swarm/secrets/) for additional security.
From fd83280b70b515098e9cdc4e95de163071013e11 Mon Sep 17 00:00:00 2001
From: mmomjian <50788000+mmomjian@users.noreply.github.com>
Date: Sun, 31 Mar 2024 21:52:20 -0400
Subject: [PATCH 5/7] docs: Postgres standalone fix (#8427)
---
docs/docs/administration/postgres-standalone.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md
index 4c3c77455..c29aa54e6 100644
--- a/docs/docs/administration/postgres-standalone.md
+++ b/docs/docs/administration/postgres-standalone.md
@@ -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.
@@ -45,7 +45,7 @@ CREATE EXTENSION vectors;
CREATE EXTENSION earthdistance CASCADE;
ALTER DATABASE SET search_path TO "$user", public, vectors;
GRANT USAGE ON SCHEMA vectors TO ;
-GRANT SELECT ON TABLE pg_vector_index_stat to ;
+ALTER DEFAULT PRIVILEGES IN SCHEMA vectors GRANT SELECT ON TABLES TO ;
COMMIT;
```
From 861b72ef0493bb6272c1a1b16e5cdee6792fb4b6 Mon Sep 17 00:00:00 2001
From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com>
Date: Mon, 1 Apr 2024 06:14:35 +0200
Subject: [PATCH 6/7] fix(mobile): update album date range on add/remove
(#8324)
---
.../modules/album/services/album.service.dart | 53 ++++++++++++-------
1 file changed, 33 insertions(+), 20 deletions(-)
diff --git a/mobile/lib/modules/album/services/album.service.dart b/mobile/lib/modules/album/services/album.service.dart
index f66a30f31..a72620b86 100644
--- a/mobile/lib/modules/album/services/album.service.dart
+++ b/mobile/lib/modules/album/services/album.service.dart
@@ -243,12 +243,7 @@ class AlbumService {
}
}
- await _db.writeTxn(() async {
- await album.assets.update(link: successAssets);
- final a = await _db.albums.get(album.id);
- // trigger watcher
- await _db.albums.put(a!);
- });
+ await _updateAssets(album.id, add: successAssets);
return AddAssetsResponse(
alreadyInAlbum: duplicatedAssets,
@@ -257,11 +252,28 @@ class AlbumService {
}
} catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
- return null;
}
return null;
}
+ Future _updateAssets(
+ int albumId, {
+ Iterable add = const [],
+ Iterable 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 addAdditionalUserToAlbum(
List sharedUserIds,
Album album,
@@ -342,7 +354,7 @@ class AlbumService {
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
return true;
} catch (e) {
- debugPrint("Error deleteAlbum ${e.toString()}");
+ debugPrint("Error leaveAlbum ${e.toString()}");
return false;
}
}
@@ -352,24 +364,25 @@ class AlbumService {
Iterable assets,
) async {
try {
- await _apiService.albumApi.removeAssetFromAlbum(
+ final response = await _apiService.albumApi.removeAssetFromAlbum(
album.remoteId!,
BulkIdsDto(
ids: assets.map((asset) => asset.remoteId!).toList(),
),
);
- await _db.writeTxn(() async {
- await album.assets.update(unlink: assets);
- final a = await _db.albums.get(album.id);
- // trigger watcher
- await _db.albums.put(a!);
- });
-
- return true;
+ if (response != null) {
+ final toRemove = response.every((e) => e.success)
+ ? assets
+ : response
+ .where((e) => e.success)
+ .map((e) => assets.firstWhere((a) => a.remoteId == e.id));
+ await _updateAssets(album.id, remove: toRemove);
+ return true;
+ }
} catch (e) {
- debugPrint("Error deleteAlbum ${e.toString()}");
- return false;
+ debugPrint("Error removeAssetFromAlbum ${e.toString()}");
}
+ return false;
}
Future removeUserFromAlbum(
@@ -413,7 +426,7 @@ class AlbumService {
return true;
} catch (e) {
- debugPrint("Error deleteAlbum ${e.toString()}");
+ debugPrint("Error changeTitleAlbum ${e.toString()}");
return false;
}
}
From 27be81301175e6698cc0236d7a7679db53d6ded1 Mon Sep 17 00:00:00 2001
From: Alex
Date: Mon, 1 Apr 2024 09:45:11 -0500
Subject: [PATCH 7/7] feat(mobile): search enhancement (#8392)
---
mobile/ios/Podfile.lock | 27 +-
.../home/ui/asset_grid/immich_asset_grid.dart | 2 +-
.../search/models/curated_content.dart | 75 +-
.../modules/search/models/search_filter.dart | 310 ++
.../providers/paginated_search.provider.dart | 62 +
.../paginated_search.provider.g.dart | 44 +
.../search/providers/people.provider.dart | 100 +-
.../search/providers/people.provider.g.dart | 24 +-
.../providers/search_filter.provider.dart | 27 +
.../providers/search_filter.provider.g.dart | 229 ++
.../search_result_page.provider.dart | 67 -
.../search/services/person.service.dart | 2 +-
.../search/services/search.service.dart | 94 +-
.../lib/modules/search/ui/explore_grid.dart | 19 +-
.../modules/search/ui/immich_search_bar.dart | 99 -
.../ui/search_filter/camera_picker.dart | 120 +
.../search_filter/display_option_picker.dart | 68 +
.../filter_bottom_sheet_scaffold.dart | 68 +
.../ui/search_filter/location_picker.dart | 166 +
.../ui/search_filter/media_type_picker.dart | 48 +
.../ui/search_filter/people_picker.dart | 81 +
.../ui/search_filter/search_filter_chip.dart | 68 +
.../ui/search_filter/search_filter_utils.dart | 19 +
.../search/ui/search_suggestion_list.dart | 66 -
.../modules/search/views/all_people_page.dart | 73 +-
.../search/views/search_input_page.dart | 563 +++
.../lib/modules/search/views/search_page.dart | 553 ++-
.../search/views/search_result_page.dart | 213 -
mobile/lib/routing/router.dart | 12 +-
mobile/lib/routing/router.gr.dart | 113 +-
.../lib/routing/tab_navigation_observer.dart | 2 +-
.../ui/asset_grid/multiselect_grid.dart | 5 +-
.../lib/shared/views/tab_controller_page.dart | 18 +-
mobile/lib/utils/immich_app_theme.dart | 7 +
mobile/pubspec.lock | 3624 ++++++++---------
35 files changed, 4302 insertions(+), 2766 deletions(-)
create mode 100644 mobile/lib/modules/search/models/search_filter.dart
create mode 100644 mobile/lib/modules/search/providers/paginated_search.provider.dart
create mode 100644 mobile/lib/modules/search/providers/paginated_search.provider.g.dart
create mode 100644 mobile/lib/modules/search/providers/search_filter.provider.dart
create mode 100644 mobile/lib/modules/search/providers/search_filter.provider.g.dart
delete mode 100644 mobile/lib/modules/search/providers/search_result_page.provider.dart
delete mode 100644 mobile/lib/modules/search/ui/immich_search_bar.dart
create mode 100644 mobile/lib/modules/search/ui/search_filter/camera_picker.dart
create mode 100644 mobile/lib/modules/search/ui/search_filter/display_option_picker.dart
create mode 100644 mobile/lib/modules/search/ui/search_filter/filter_bottom_sheet_scaffold.dart
create mode 100644 mobile/lib/modules/search/ui/search_filter/location_picker.dart
create mode 100644 mobile/lib/modules/search/ui/search_filter/media_type_picker.dart
create mode 100644 mobile/lib/modules/search/ui/search_filter/people_picker.dart
create mode 100644 mobile/lib/modules/search/ui/search_filter/search_filter_chip.dart
create mode 100644 mobile/lib/modules/search/ui/search_filter/search_filter_utils.dart
delete mode 100644 mobile/lib/modules/search/ui/search_suggestion_list.dart
create mode 100644 mobile/lib/modules/search/views/search_input_page.dart
delete mode 100644 mobile/lib/modules/search/views/search_result_page.dart
diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
index e77749ffd..5493fc284 100644
--- a/mobile/ios/Podfile.lock
+++ b/mobile/ios/Podfile.lock
@@ -17,6 +17,9 @@ PODS:
- fluttertoast (0.0.2):
- Flutter
- Toast
+ - FMDB (2.7.5):
+ - FMDB/standard (= 2.7.5)
+ - FMDB/standard (2.7.5)
- geolocator_apple (1.2.0):
- Flutter
- image_picker_ios (0.0.1):
@@ -36,7 +39,7 @@ PODS:
- FlutterMacOS
- path_provider_ios (0.0.1):
- Flutter
- - permission_handler_apple (9.3.0):
+ - permission_handler_apple (9.1.1):
- Flutter
- photo_manager (2.0.0):
- Flutter
@@ -50,7 +53,7 @@ PODS:
- FlutterMacOS
- sqflite (0.0.3):
- Flutter
- - FlutterMacOS
+ - FMDB (>= 2.7.5)
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
@@ -81,13 +84,14 @@ DEPENDENCIES:
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- - sqflite (from `.symlinks/plugins/sqflite/darwin`)
+ - sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
+ - FMDB
- MapLibre
- ReachabilitySwift
- SAMKeychain
@@ -135,7 +139,7 @@ EXTERNAL SOURCES:
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
- :path: ".symlinks/plugins/sqflite/darwin"
+ :path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
@@ -151,23 +155,24 @@ SPEC CHECKSUMS:
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
- fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
+ fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
+ FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
geolocator_apple: 9157311f654584b9bb72686c55fc02a97b73f461
- image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
+ image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
- path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
+ path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
- permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78
+ permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
- shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
- sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
+ shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
+ sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579
@@ -175,4 +180,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
-COCOAPODS: 1.12.1
+COCOAPODS: 1.15.2
diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
index 687e7aaac..f075280ae 100644
--- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
+++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
@@ -42,7 +42,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.assetsPerRow,
this.showStorageIndicator,
this.listener,
- this.margin = 5.0,
+ this.margin = 2.0,
this.selectionActive = false,
this.preselectedAssets,
this.canDeselect = true,
diff --git a/mobile/lib/modules/search/models/curated_content.dart b/mobile/lib/modules/search/models/curated_content.dart
index df7cb032c..87e98bb75 100644
--- a/mobile/lib/modules/search/models/curated_content.dart
+++ b/mobile/lib/modules/search/models/curated_content.dart
@@ -1,15 +1,60 @@
-/// A wrapper for [CuratedLocationsResponseDto] objects
-/// and [CuratedObjectsResponseDto] to be displayed in
-/// a view
-class CuratedContent {
- /// The label to show associated with this curated object
- final String label;
-
- /// The id to lookup the asset from the server
- final String id;
-
- CuratedContent({
- required this.id,
- required this.label,
- });
-}
+// ignore_for_file: public_member_api_docs, sort_constructors_first
+import 'dart:convert';
+
+/// A wrapper for [CuratedLocationsResponseDto] objects
+/// and [CuratedObjectsResponseDto] to be displayed in
+/// a view
+class CuratedContent {
+ /// The label to show associated with this curated object
+ final String label;
+
+ /// The id to lookup the asset from the server
+ final String id;
+
+ CuratedContent({
+ required this.label,
+ required this.id,
+ });
+
+ CuratedContent copyWith({
+ String? label,
+ String? id,
+ }) {
+ return CuratedContent(
+ label: label ?? this.label,
+ id: id ?? this.id,
+ );
+ }
+
+ Map toMap() {
+ return {
+ 'label': label,
+ 'id': id,
+ };
+ }
+
+ factory CuratedContent.fromMap(Map 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);
+
+ @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;
+}
diff --git a/mobile/lib/modules/search/models/search_filter.dart b/mobile/lib/modules/search/models/search_filter.dart
new file mode 100644
index 000000000..337da9266
--- /dev/null
+++ b/mobile/lib/modules/search/models/search_filter.dart
@@ -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 toMap() {
+ return {
+ 'country': country,
+ 'state': state,
+ 'city': city,
+ };
+ }
+
+ factory SearchLocationFilter.fromMap(Map 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);
+
+ @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 toMap() {
+ return {
+ 'make': make,
+ 'model': model,
+ };
+ }
+
+ factory SearchCameraFilter.fromMap(Map 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);
+
+ @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 toMap() {
+ return {
+ 'takenBefore': takenBefore?.millisecondsSinceEpoch,
+ 'takenAfter': takenAfter?.millisecondsSinceEpoch,
+ };
+ }
+
+ factory SearchDateFilter.fromMap(Map 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);
+
+ @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 toMap() {
+ return {
+ 'isNotInAlbum': isNotInAlbum,
+ 'isArchive': isArchive,
+ 'isFavorite': isFavorite,
+ };
+ }
+
+ factory SearchDisplayFilters.fromMap(Map 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);
+
+ @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 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? 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;
+ }
+}
diff --git a/mobile/lib/modules/search/providers/paginated_search.provider.dart b/mobile/lib/modules/search/providers/paginated_search.provider.dart
new file mode 100644
index 000000000..e20e37c52
--- /dev/null
+++ b/mobile/lib/modules/search/providers/paginated_search.provider.dart
@@ -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?> _search(SearchFilter filter, int page) async {
+ final service = ref.read(searchServiceProvider);
+ final result = await service.search(filter, page);
+
+ return result;
+ }
+
+ @override
+ Future> build() async {
+ return [];
+ }
+
+ Future> 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 paginatedSearchRenderList(
+ PaginatedSearchRenderListRef ref,
+) {
+ final assets = ref.watch(paginatedSearchProvider).value;
+
+ if (assets != null) {
+ return ref.watch(
+ renderListProviderWithGrouping(
+ (assets, GroupAssetsBy.none),
+ ),
+ );
+ } else {
+ return const AsyncValue.loading();
+ }
+}
diff --git a/mobile/lib/modules/search/providers/paginated_search.provider.g.dart b/mobile/lib/modules/search/providers/paginated_search.provider.g.dart
new file mode 100644
index 000000000..3357be777
--- /dev/null
+++ b/mobile/lib/modules/search/providers/paginated_search.provider.g.dart
@@ -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>.internal(
+ paginatedSearchRenderList,
+ name: r'paginatedSearchRenderListProvider',
+ debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
+ ? null
+ : _$paginatedSearchRenderListHash,
+ dependencies: null,
+ allTransitiveDependencies: null,
+);
+
+typedef PaginatedSearchRenderListRef
+ = AutoDisposeProviderRef>;
+String _$paginatedSearchHash() => r'8312f358261368cf2b5572b839fdd8f8fbe9a62e';
+
+/// See also [PaginatedSearch].
+@ProviderFor(PaginatedSearch)
+final paginatedSearchProvider =
+ AutoDisposeAsyncNotifierProvider>.internal(
+ PaginatedSearch.new,
+ name: r'paginatedSearchProvider',
+ debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
+ ? null
+ : _$paginatedSearchHash,
+ dependencies: null,
+ allTransitiveDependencies: null,
+);
+
+typedef _$PaginatedSearch = AutoDisposeAsyncNotifier>;
+// ignore_for_file: type=lint
+// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
diff --git a/mobile/lib/modules/search/providers/people.provider.dart b/mobile/lib/modules/search/providers/people.provider.dart
index 6009ee53a..398d1122a 100644
--- a/mobile/lib/modules/search/providers/people.provider.dart
+++ b/mobile/lib/modules/search/providers/people.provider.dart
@@ -1,51 +1,49 @@
-import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
-import 'package:immich_mobile/modules/search/models/curated_content.dart';
-import 'package:immich_mobile/modules/search/services/person.service.dart';
-import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
-import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
-import 'package:riverpod_annotation/riverpod_annotation.dart';
-
-part 'people.provider.g.dart';
-
-@riverpod
-Future> getCuratedPeople(
- GetCuratedPeopleRef ref,
-) async {
- final PersonService personService = ref.read(personServiceProvider);
-
- final curatedPeople = await personService.getCuratedPeople();
-
- return curatedPeople
- .map((p) => CuratedContent(id: p.id, label: p.name))
- .toList();
-}
-
-@riverpod
-Future personAssets(PersonAssetsRef ref, String personId) async {
- final PersonService personService = ref.read(personServiceProvider);
- final assets = await personService.getPersonAssets(personId);
- if (assets == null) {
- return RenderList.empty();
- }
-
- final settings = ref.read(appSettingsServiceProvider);
- final groupBy =
- GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
- return await RenderList.fromAssets(assets, groupBy);
-}
-
-@riverpod
-Future updatePersonName(
- UpdatePersonNameRef ref,
- String personId,
- String updatedName,
-) async {
- final PersonService personService = ref.read(personServiceProvider);
- final person = await personService.updateName(personId, updatedName);
-
- if (person != null && person.name == updatedName) {
- ref.invalidate(getCuratedPeopleProvider);
- return true;
- }
- return false;
-}
+import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/modules/search/services/person.service.dart';
+import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:openapi/api.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+part 'people.provider.g.dart';
+
+@riverpod
+Future> getAllPeople(
+ GetAllPeopleRef ref,
+) async {
+ final PersonService personService = ref.read(personServiceProvider);
+
+ final people = await personService.getAllPeople();
+
+ return people;
+}
+
+@riverpod
+Future personAssets(PersonAssetsRef ref, String personId) async {
+ final PersonService personService = ref.read(personServiceProvider);
+ final assets = await personService.getPersonAssets(personId);
+ if (assets == null) {
+ return RenderList.empty();
+ }
+
+ final settings = ref.read(appSettingsServiceProvider);
+ final groupBy =
+ GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
+ return await RenderList.fromAssets(assets, groupBy);
+}
+
+@riverpod
+Future updatePersonName(
+ UpdatePersonNameRef ref,
+ String personId,
+ String updatedName,
+) async {
+ final PersonService personService = ref.read(personServiceProvider);
+ final person = await personService.updateName(personId, updatedName);
+
+ if (person != null && person.name == updatedName) {
+ ref.invalidate(getAllPeopleProvider);
+ return true;
+ }
+ return false;
+}
diff --git a/mobile/lib/modules/search/providers/people.provider.g.dart b/mobile/lib/modules/search/providers/people.provider.g.dart
index c13c2c160..c68f7a75f 100644
--- a/mobile/lib/modules/search/providers/people.provider.g.dart
+++ b/mobile/lib/modules/search/providers/people.provider.g.dart
@@ -6,23 +6,21 @@ part of 'people.provider.dart';
// RiverpodGenerator
// **************************************************************************
-String _$getCuratedPeopleHash() => r'2a534553812abe69abce2c2e41aa62b8de16e9d0';
+String _$getAllPeopleHash() => r'4eff6666be5a74710d1e8587e01d8154310d85bd';
-/// See also [getCuratedPeople].
-@ProviderFor(getCuratedPeople)
-final getCuratedPeopleProvider =
- AutoDisposeFutureProvider>.internal(
- getCuratedPeople,
- name: r'getCuratedPeopleProvider',
- debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
- ? null
- : _$getCuratedPeopleHash,
+/// See also [getAllPeople].
+@ProviderFor(getAllPeople)
+final getAllPeopleProvider =
+ AutoDisposeFutureProvider>.internal(
+ getAllPeople,
+ name: r'getAllPeopleProvider',
+ debugGetCreateSourceHash:
+ const bool.fromEnvironment('dart.vm.product') ? null : _$getAllPeopleHash,
dependencies: null,
allTransitiveDependencies: null,
);
-typedef GetCuratedPeopleRef
- = AutoDisposeFutureProviderRef>;
+typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>;
String _$personAssetsHash() => r'1d6eff5ca3aa630b58c4dad9516193b21896984d';
/// Copied from Dart SDK
@@ -172,7 +170,7 @@ class _PersonAssetsProviderElement
String get personId => (origin as PersonAssetsProvider).personId;
}
-String _$updatePersonNameHash() => r'c7179a7cc558669c3b30b03fbca7782a42f2b6fd';
+String _$updatePersonNameHash() => r'7145aaaf6fc38fdafe3a283ebf3d3f4fd0774cd2';
/// See also [updatePersonName].
@ProviderFor(updatePersonName)
diff --git a/mobile/lib/modules/search/providers/search_filter.provider.dart b/mobile/lib/modules/search/providers/search_filter.provider.dart
new file mode 100644
index 000000000..1a4914b41
--- /dev/null
+++ b/mobile/lib/modules/search/providers/search_filter.provider.dart
@@ -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> 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 ?? [];
+}
diff --git a/mobile/lib/modules/search/providers/search_filter.provider.g.dart b/mobile/lib/modules/search/providers/search_filter.provider.g.dart
new file mode 100644
index 000000000..d5cdaa031
--- /dev/null
+++ b/mobile/lib/modules/search/providers/search_filter.provider.g.dart
@@ -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>> {
+ /// 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? _dependencies = null;
+
+ @override
+ Iterable? get dependencies => _dependencies;
+
+ static const Iterable? _allTransitiveDependencies = null;
+
+ @override
+ Iterable? get allTransitiveDependencies =>
+ _allTransitiveDependencies;
+
+ @override
+ String? get name => r'getSearchSuggestionsProvider';
+}
+
+/// See also [getSearchSuggestions].
+class GetSearchSuggestionsProvider
+ extends AutoDisposeFutureProvider> {
+ /// 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> 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> 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> {
+ /// 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>
+ 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
diff --git a/mobile/lib/modules/search/providers/search_result_page.provider.dart b/mobile/lib/modules/search/providers/search_result_page.provider.dart
deleted file mode 100644
index e220cc69f..000000000
--- a/mobile/lib/modules/search/providers/search_result_page.provider.dart
+++ /dev/null
@@ -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 {
- SearchResultPageNotifier(this._searchService)
- : super(
- SearchResultPageState(
- searchResult: [],
- isError: false,
- isLoading: true,
- isSuccess: false,
- isSmart: false,
- ),
- );
-
- final SearchService _searchService;
-
- Future search(String searchTerm, {bool smartSearch = true}) async {
- state = state.copyWith(
- searchResult: [],
- isError: false,
- isLoading: true,
- isSuccess: false,
- );
-
- List? 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(
- (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),
- ),
- );
-});
diff --git a/mobile/lib/modules/search/services/person.service.dart b/mobile/lib/modules/search/services/person.service.dart
index 4f92e729f..884a01c9f 100644
--- a/mobile/lib/modules/search/services/person.service.dart
+++ b/mobile/lib/modules/search/services/person.service.dart
@@ -20,7 +20,7 @@ class PersonService {
PersonService(this._apiService, this._db);
- Future> getCuratedPeople() async {
+ Future> getAllPeople() async {
try {
final peopleResponseDto = await _apiService.personApi.getAllPeople();
return peopleResponseDto?.people ?? [];
diff --git a/mobile/lib/modules/search/services/search.service.dart b/mobile/lib/modules/search/services/search.service.dart
index 35249dec5..4d19657af 100644
--- a/mobile/lib/modules/search/services/search.service.dart
+++ b/mobile/lib/modules/search/services/search.service.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
@@ -29,25 +30,92 @@ class SearchService {
}
}
- Future?> searchAsset(
- String searchTerm, {
- bool smartSearch = true,
+ Future?> getSearchSuggestions(
+ SearchSuggestionType type, {
+ String? country,
+ String? state,
+ String? make,
+ String? model,
}) async {
- // TODO search in local DB: 1. when offline, 2. to find local assets
try {
- final SearchResponseDto? results = await _apiService.searchApi.search(
- query: searchTerm,
- smart: smartSearch,
+ return await _apiService.searchApi.getSearchSuggestions(
+ type,
+ country: country,
+ state: state,
+ make: make,
+ model: model,
);
- if (results == null) {
+ } catch (e) {
+ debugPrint("[ERROR] [getSearchSuggestions] ${e.toString()}");
+ return [];
+ }
+ }
+
+ Future?> search(SearchFilter filter, int page) async {
+ try {
+ SearchResponseDto? response;
+ AssetTypeEnum? type;
+ if (filter.mediaType == AssetType.image) {
+ type = AssetTypeEnum.IMAGE;
+ } else if (filter.mediaType == AssetType.video) {
+ type = AssetTypeEnum.VIDEO;
+ }
+
+ if (filter.context != null && filter.context!.isNotEmpty) {
+ response = await _apiService.searchApi.searchSmart(
+ SmartSearchDto(
+ query: filter.context!,
+ country: filter.location.country,
+ state: filter.location.state,
+ city: filter.location.city,
+ make: filter.camera.make,
+ model: filter.camera.model,
+ takenAfter: filter.date.takenAfter,
+ takenBefore: filter.date.takenBefore,
+ isArchived: filter.display.isArchive,
+ isFavorite: filter.display.isFavorite,
+ isNotInAlbum: filter.display.isNotInAlbum,
+ personIds: filter.people.map((e) => e.id).toList(),
+ type: type,
+ page: page,
+ size: 1000,
+ ),
+ );
+ } else {
+ response = await _apiService.searchApi.searchMetadata(
+ MetadataSearchDto(
+ originalFileName:
+ filter.filename != null && filter.filename!.isNotEmpty
+ ? filter.filename
+ : null,
+ country: filter.location.country,
+ state: filter.location.state,
+ city: filter.location.city,
+ make: filter.camera.make,
+ model: filter.camera.model,
+ takenAfter: filter.date.takenAfter,
+ takenBefore: filter.date.takenBefore,
+ isArchived: filter.display.isArchive,
+ isFavorite: filter.display.isFavorite,
+ isNotInAlbum: filter.display.isNotInAlbum,
+ personIds: filter.people.map((e) => e.id).toList(),
+ type: type,
+ page: page,
+ size: 1000,
+ ),
+ );
+ }
+
+ if (response == null) {
return null;
}
- // TODO local DB might be out of date; add assets not yet in DB?
- return _db.assets.getAllByRemoteId(results.assets.items.map((e) => e.id));
- } catch (e) {
- debugPrint("[ERROR] [searchAsset] ${e.toString()}");
- return null;
+
+ return _db.assets
+ .getAllByRemoteId(response.assets.items.map((e) => e.id));
+ } catch (error) {
+ debugPrint("Error [search] $error");
}
+ return null;
}
Future?> getCuratedLocation() async {
diff --git a/mobile/lib/modules/search/ui/explore_grid.dart b/mobile/lib/modules/search/ui/explore_grid.dart
index fd49fff7c..ba55b5581 100644
--- a/mobile/lib/modules/search/ui/explore_grid.dart
+++ b/mobile/lib/modules/search/ui/explore_grid.dart
@@ -1,8 +1,10 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
+import 'package:immich_mobile/modules/search/models/search_filter.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -57,7 +59,22 @@ class ExploreGrid extends StatelessWidget {
),
)
: context.pushRoute(
- SearchResultRoute(searchTerm: 'm:${content.label}'),
+ SearchInputRoute(
+ prefilter: SearchFilter(
+ people: {},
+ location: SearchLocationFilter(
+ city: content.label,
+ ),
+ camera: SearchCameraFilter(),
+ date: SearchDateFilter(),
+ display: SearchDisplayFilters(
+ isNotInAlbum: false,
+ isArchive: false,
+ isFavorite: false,
+ ),
+ mediaType: AssetType.other,
+ ),
+ ),
);
},
);
diff --git a/mobile/lib/modules/search/ui/immich_search_bar.dart b/mobile/lib/modules/search/ui/immich_search_bar.dart
deleted file mode 100644
index f4fa62d26..000000000
--- a/mobile/lib/modules/search/ui/immich_search_bar.dart
+++ /dev/null
@@ -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();
- }
-}
diff --git a/mobile/lib/modules/search/ui/search_filter/camera_picker.dart b/mobile/lib/modules/search/ui/search_filter/camera_picker.dart
new file mode 100644
index 000000000..fdfd398e6
--- /dev/null
+++ b/mobile/lib/modules/search/ui/search_filter/camera_picker.dart
@@ -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) 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(filter?.make);
+ final selectedModel = useState(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(
+ 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,
+ });
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/modules/search/ui/search_filter/display_option_picker.dart b/mobile/lib/modules/search/ui/search_filter/display_option_picker.dart
new file mode 100644
index 000000000..f6cd01cbb
--- /dev/null
+++ b/mobile/lib/modules/search/ui/search_filter/display_option_picker.dart
@@ -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) onSelect;
+ final SearchDisplayFilters? filter;
+
+ @override
+ Widget build(BuildContext context) {
+ final options = useState