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; ``` 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 9fc1b20d2..9da1f3ce9 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 @@ -147,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. 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/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; } } 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>({ + 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); + }, + ), + ], + ); + } +} diff --git a/mobile/lib/modules/search/ui/search_filter/filter_bottom_sheet_scaffold.dart b/mobile/lib/modules/search/ui/search_filter/filter_bottom_sheet_scaffold.dart new file mode 100644 index 000000000..46bfe96bb --- /dev/null +++ b/mobile/lib/modules/search/ui/search_filter/filter_bottom_sheet_scaffold.dart @@ -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'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/modules/search/ui/search_filter/location_picker.dart b/mobile/lib/modules/search/ui/search_filter/location_picker.dart new file mode 100644 index 000000000..22568da47 --- /dev/null +++ b/mobile/lib/modules/search/ui/search_filter/location_picker.dart @@ -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) 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(filter?.country); + final selectedState = useState(filter?.state); + final selectedCity = useState(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( + 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, + }); + }, + ), + ], + ); + } +} diff --git a/mobile/lib/modules/search/ui/search_filter/media_type_picker.dart b/mobile/lib/modules/search/ui/search_filter/media_type_picker.dart new file mode 100644 index 000000000..aaef2c815 --- /dev/null +++ b/mobile/lib/modules/search/ui/search_filter/media_type_picker.dart @@ -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, + ), + ], + ); + } +} diff --git a/mobile/lib/modules/search/ui/search_filter/people_picker.dart b/mobile/lib/modules/search/ui/search_filter/people_picker.dart new file mode 100644 index 000000000..74aad06b8 --- /dev/null +++ b/mobile/lib/modules/search/ui/search_filter/people_picker.dart @@ -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) onSelect; + final Set? 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>(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)), + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/mobile/lib/modules/search/ui/search_filter/search_filter_chip.dart b/mobile/lib/modules/search/ui/search_filter/search_filter_chip.dart new file mode 100644 index 000000000..b2e0d086a --- /dev/null +++ b/mobile/lib/modules/search/ui/search_filter/search_filter_chip.dart @@ -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), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/search/ui/search_filter/search_filter_utils.dart b/mobile/lib/modules/search/ui/search_filter/search_filter_utils.dart new file mode 100644 index 000000000..57545413d --- /dev/null +++ b/mobile/lib/modules/search/ui/search_filter/search_filter_utils.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +Future showFilterBottomSheet({ + 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; + }, + ); +} diff --git a/mobile/lib/modules/search/ui/search_suggestion_list.dart b/mobile/lib/modules/search/ui/search_suggestion_list.dart deleted file mode 100644 index c9694eb75..000000000 --- a/mobile/lib/modules/search/ui/search_suggestion_list.dart +++ /dev/null @@ -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, - ), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/modules/search/views/all_people_page.dart b/mobile/lib/modules/search/views/all_people_page.dart index 1f90922c1..3414edc05 100644 --- a/mobile/lib/modules/search/views/all_people_page.dart +++ b/mobile/lib/modules/search/views/all_people_page.dart @@ -1,35 +1,38 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/modules/search/providers/people.provider.dart'; -import 'package:immich_mobile/modules/search/ui/explore_grid.dart'; - -@RoutePage() -class AllPeoplePage extends HookConsumerWidget { - const AllPeoplePage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final curatedPeople = ref.watch(getCuratedPeopleProvider); - - return Scaffold( - appBar: AppBar( - title: const Text( - 'all_people_page_title', - ).tr(), - leading: IconButton( - onPressed: () => context.popRoute(), - icon: const Icon(Icons.arrow_back_ios_rounded), - ), - ), - body: curatedPeople.widgetWhen( - onData: (people) => ExploreGrid( - isPeople: true, - curatedContent: people, - ), - ), - ); - } -} +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/modules/search/models/curated_content.dart'; +import 'package:immich_mobile/modules/search/providers/people.provider.dart'; +import 'package:immich_mobile/modules/search/ui/explore_grid.dart'; + +@RoutePage() +class AllPeoplePage extends HookConsumerWidget { + const AllPeoplePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final curatedPeople = ref.watch(getAllPeopleProvider); + + return Scaffold( + appBar: AppBar( + title: const Text( + 'all_people_page_title', + ).tr(), + leading: IconButton( + onPressed: () => context.popRoute(), + icon: const Icon(Icons.arrow_back_ios_rounded), + ), + ), + body: curatedPeople.widgetWhen( + onData: (people) => ExploreGrid( + isPeople: true, + curatedContent: people + .map((e) => CuratedContent(label: e.name, id: e.id)) + .toList(), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/search/views/search_input_page.dart b/mobile/lib/modules/search/views/search_input_page.dart new file mode 100644 index 000000000..a35341606 --- /dev/null +++ b/mobile/lib/modules/search/views/search_input_page.dart @@ -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( + 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(null); + final dateRangeCurrentFilterWidget = useState(null); + final cameraCurrentFilterWidget = useState(null); + final locationCurrentFilterWidget = useState(null); + final mediaTypeCurrentFilterWidget = useState(null); + final displayOptionCurrentFilterWidget = useState(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 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 value) { + filter.value = filter.value.copyWith( + location: SearchLocationFilter( + country: value['country'], + city: value['city'], + state: value['state'], + ), + ); + + final locationText = []; + 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 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 value) { + final filterText = []; + + 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( + 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(), + ], + ), + ); + } +} diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index ab114d691..27ca28126 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -1,279 +1,274 @@ -import 'dart:math' as math; -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/modules/search/models/curated_content.dart'; -import 'package:immich_mobile/modules/search/providers/people.provider.dart'; -import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; -import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; -import 'package:immich_mobile/modules/search/ui/curated_places_row.dart'; -import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart'; -import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; -import 'package:immich_mobile/modules/search/ui/search_row_title.dart'; -import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/shared/providers/server_info.provider.dart'; -import 'package:immich_mobile/shared/ui/scaffold_error_body.dart'; - -@RoutePage() -// ignore: must_be_immutable -class SearchPage extends HookConsumerWidget { - SearchPage({super.key}); - - FocusNode searchFocusNode = FocusNode(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled; - final curatedLocation = ref.watch(getCuratedLocationProvider); - final curatedPeople = ref.watch(getCuratedPeopleProvider); - final isMapEnabled = - ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map)); - double imageSize = math.min(context.width / 3, 150); - - TextStyle categoryTitleStyle = const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 15.0, - ); - - Color categoryIconColor = context.isDarkTheme ? Colors.white : Colors.black; - - useEffect( - () { - searchFocusNode = FocusNode(); - return () => searchFocusNode.dispose(); - }, - [], - ); - - onSearchSubmitted(String searchTerm) async { - searchFocusNode.unfocus(); - ref.watch(searchPageStateProvider.notifier).disableSearch(); - - context.pushRoute( - SearchResultRoute( - searchTerm: searchTerm, - ), - ); - } - - showNameEditModel( - String personId, - String personName, - ) { - return showDialog( - context: context, - builder: (BuildContext context) { - return PersonNameEditForm(personId: personId, personName: personName); - }, - ); - } - - buildPeople() { - return SizedBox( - height: imageSize, - child: curatedPeople.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (people) => Padding( - padding: const EdgeInsets.only( - left: 16, - top: 8, - ), - child: CuratedPeopleRow( - content: people.take(12).toList(), - onTap: (content, index) { - context.pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ); - }, - onNameTap: (person, index) => { - showNameEditModel(person.id, person.label), - }, - ), - ), - ), - ); - } - - buildPlaces() { - return SizedBox( - height: imageSize, - child: curatedLocation.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (locations) => CuratedPlacesRow( - isMapEnabled: isMapEnabled, - content: locations - .map( - (o) => CuratedContent( - id: o.id, - label: o.city, - ), - ) - .toList(), - imageSize: imageSize, - onTap: (content, index) { - context.pushRoute( - SearchResultRoute( - searchTerm: 'm:${content.label}', - ), - ); - }, - ), - ), - ); - } - - return Scaffold( - appBar: ImmichSearchBar( - searchFocusNode: searchFocusNode, - onSubmitted: onSearchSubmitted, - ), - body: GestureDetector( - onTap: () { - searchFocusNode.unfocus(); - ref.watch(searchPageStateProvider.notifier).disableSearch(); - }, - child: Stack( - children: [ - ListView( - children: [ - SearchRowTitle( - title: "search_page_people".tr(), - onViewAllPressed: () => - context.pushRoute(const AllPeopleRoute()), - ), - buildPeople(), - SearchRowTitle( - title: "search_page_places".tr(), - onViewAllPressed: () => - context.pushRoute(const CuratedLocationRoute()), - top: 0, - ), - const SizedBox(height: 10.0), - buildPlaces(), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'search_page_your_activity', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - leading: Icon( - Icons.favorite_border_rounded, - color: categoryIconColor, - ), - title: - Text('search_page_favorites', style: categoryTitleStyle) - .tr(), - onTap: () => context.pushRoute(const FavoritesRoute()), - ), - const CategoryDivider(), - ListTile( - leading: Icon( - Icons.schedule_outlined, - color: categoryIconColor, - ), - title: Text( - 'search_page_recently_added', - style: categoryTitleStyle, - ).tr(), - onTap: () => context.pushRoute(const RecentlyAddedRoute()), - ), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'search_page_categories', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - title: - Text('search_page_screenshots', style: categoryTitleStyle) - .tr(), - leading: Icon( - Icons.screenshot, - color: categoryIconColor, - ), - onTap: () => context.pushRoute( - SearchResultRoute( - searchTerm: 'screenshots', - ), - ), - ), - const CategoryDivider(), - ListTile( - title: Text('search_page_selfies', style: categoryTitleStyle) - .tr(), - leading: Icon( - Icons.photo_camera_front_outlined, - color: categoryIconColor, - ), - onTap: () => context.pushRoute( - SearchResultRoute( - searchTerm: 'selfies', - ), - ), - ), - const CategoryDivider(), - ListTile( - title: Text('search_page_videos', style: categoryTitleStyle) - .tr(), - leading: Icon( - Icons.play_circle_outline, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllVideosRoute()), - ), - const CategoryDivider(), - ListTile( - title: Text( - 'search_page_motion_photos', - style: categoryTitleStyle, - ).tr(), - leading: Icon( - Icons.motion_photos_on_outlined, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllMotionPhotosRoute()), - ), - ], - ), - if (isSearchEnabled) - SearchSuggestionList(onSubmitted: onSearchSubmitted), - ], - ), - ), - ); - } -} - -class CategoryDivider extends StatelessWidget { - const CategoryDivider({super.key}); - - @override - Widget build(BuildContext context) { - return const Padding( - padding: EdgeInsets.only( - left: 56, - right: 16, - ), - child: Divider( - height: 0, - ), - ); - } -} +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/search/models/curated_content.dart'; +import 'package:immich_mobile/modules/search/models/search_filter.dart'; +import 'package:immich_mobile/modules/search/providers/people.provider.dart'; +import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; +import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; +import 'package:immich_mobile/modules/search/ui/curated_places_row.dart'; +import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; +import 'package:immich_mobile/modules/search/ui/search_row_title.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; +import 'package:immich_mobile/shared/ui/immich_app_bar.dart'; +import 'package:immich_mobile/shared/ui/scaffold_error_body.dart'; + +@RoutePage() +// ignore: must_be_immutable +class SearchPage extends HookConsumerWidget { + const SearchPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final curatedLocation = ref.watch(getCuratedLocationProvider); + final curatedPeople = ref.watch(getAllPeopleProvider); + final isMapEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map)); + double imageSize = math.min(context.width / 3, 150); + + TextStyle categoryTitleStyle = const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 15.0, + ); + + Color categoryIconColor = context.isDarkTheme ? Colors.white : Colors.black; + + showNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ); + } + + buildPeople() { + return SizedBox( + height: imageSize, + child: curatedPeople.widgetWhen( + onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), + onData: (people) => Padding( + padding: const EdgeInsets.only( + left: 16, + top: 8, + ), + child: CuratedPeopleRow( + content: people + .map((e) => CuratedContent(label: e.name, id: e.id)) + .take(12) + .toList(), + onTap: (content, index) { + context.pushRoute( + PersonResultRoute( + personId: content.id, + personName: content.label, + ), + ); + }, + onNameTap: (person, index) => { + showNameEditModel(person.id, person.label), + }, + ), + ), + ), + ); + } + + buildPlaces() { + return SizedBox( + height: imageSize, + child: curatedLocation.widgetWhen( + onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), + onData: (locations) => CuratedPlacesRow( + isMapEnabled: isMapEnabled, + content: locations + .map( + (o) => CuratedContent( + id: o.id, + label: o.city, + ), + ) + .toList(), + imageSize: imageSize, + onTap: (content, index) { + context.pushRoute( + SearchInputRoute( + prefilter: SearchFilter( + people: {}, + location: SearchLocationFilter( + city: content.label, + ), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: AssetType.other, + ), + ), + ); + }, + ), + ), + ); + } + + buildSearchButton() { + return GestureDetector( + onTap: () { + context.pushRoute(SearchInputRoute()); + }, + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: context.isDarkTheme + ? Colors.grey[800]! + : const Color.fromARGB(255, 225, 225, 225), + ), + ), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + child: Row( + children: [ + Icon(Icons.search, color: context.primaryColor), + const SizedBox(width: 16.0), + Text( + "Search your photos", + style: context.textTheme.bodyLarge?.copyWith( + color: + context.isDarkTheme ? Colors.white70 : Colors.black54, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: const ImmichAppBar(), + body: Stack( + children: [ + ListView( + children: [ + buildSearchButton(), + SearchRowTitle( + title: "search_page_people".tr(), + onViewAllPressed: () => + context.pushRoute(const AllPeopleRoute()), + ), + buildPeople(), + SearchRowTitle( + title: "search_page_places".tr(), + onViewAllPressed: () => + context.pushRoute(const CuratedLocationRoute()), + top: 0, + ), + const SizedBox(height: 10.0), + buildPlaces(), + const SizedBox(height: 24.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'search_page_your_activity', + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ).tr(), + ), + ListTile( + leading: Icon( + Icons.favorite_border_rounded, + color: categoryIconColor, + ), + title: Text('search_page_favorites', style: categoryTitleStyle) + .tr(), + onTap: () => context.pushRoute(const FavoritesRoute()), + ), + const CategoryDivider(), + ListTile( + leading: Icon( + Icons.schedule_outlined, + color: categoryIconColor, + ), + title: Text( + 'search_page_recently_added', + style: categoryTitleStyle, + ).tr(), + onTap: () => context.pushRoute(const RecentlyAddedRoute()), + ), + const SizedBox(height: 24.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'search_page_categories', + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ).tr(), + ), + ListTile( + title: + Text('search_page_videos', style: categoryTitleStyle).tr(), + leading: Icon( + Icons.play_circle_outline, + color: categoryIconColor, + ), + onTap: () => context.pushRoute(const AllVideosRoute()), + ), + const CategoryDivider(), + ListTile( + title: Text( + 'search_page_motion_photos', + style: categoryTitleStyle, + ).tr(), + leading: Icon( + Icons.motion_photos_on_outlined, + color: categoryIconColor, + ), + onTap: () => context.pushRoute(const AllMotionPhotosRoute()), + ), + ], + ), + ], + ), + ); + } +} + +class CategoryDivider extends StatelessWidget { + const CategoryDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only( + left: 56, + right: 16, + ), + child: Divider( + height: 0, + ), + ); + } +} diff --git a/mobile/lib/modules/search/views/search_result_page.dart b/mobile/lib/modules/search/views/search_result_page.dart deleted file mode 100644 index 97df5f10c..000000000 --- a/mobile/lib/modules/search/views/search_result_page.dart +++ /dev/null @@ -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 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 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), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index f5c1a95d9..46cd7522d 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -31,7 +31,9 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart'; +import 'package:immich_mobile/modules/search/models/search_filter.dart'; import 'package:immich_mobile/modules/settings/views/settings_sub_page.dart'; +import 'package:immich_mobile/modules/search/views/search_input_page.dart'; import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; import 'package:immich_mobile/modules/shared_link/views/shared_link_edit_page.dart'; import 'package:immich_mobile/modules/shared_link/views/shared_link_page.dart'; @@ -43,7 +45,6 @@ import 'package:immich_mobile/modules/search/views/curated_location_page.dart'; import 'package:immich_mobile/modules/search/views/person_result_page.dart'; import 'package:immich_mobile/modules/search/views/recently_added_page.dart'; import 'package:immich_mobile/modules/search/views/search_page.dart'; -import 'package:immich_mobile/modules/search/views/search_result_page.dart'; import 'package:immich_mobile/modules/settings/views/settings_page.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/custom_transition_builders.dart'; @@ -125,10 +126,6 @@ class AppRouter extends _$AppRouter { page: BackupControllerRoute.page, guards: [_authGuard, _duplicateGuard, _backupPermissionGuard], ), - AutoRoute( - page: SearchResultRoute.page, - guards: [_authGuard, _duplicateGuard], - ), AutoRoute( page: CuratedLocationRoute.page, guards: [_authGuard, _duplicateGuard], @@ -223,6 +220,11 @@ class AppRouter extends _$AppRouter { page: BackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard], ), + CustomRoute( + page: SearchInputRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.noTransition, + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index cc86b701a..fa9fa32f6 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -255,22 +255,21 @@ abstract class _$AppRouter extends RootStackRouter { child: const RecentlyAddedPage(), ); }, - SearchRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const SearchRouteArgs()); + SearchInputRoute.name: (routeData) { + final args = routeData.argsAs( + orElse: () => const SearchInputRouteArgs()); return AutoRoutePage( routeData: routeData, - child: SearchPage(key: args.key), + child: SearchInputPage( + key: args.key, + prefilter: args.prefilter, + ), ); }, - SearchResultRoute.name: (routeData) { - final args = routeData.argsAs(); + SearchRoute.name: (routeData) { return AutoRoutePage( routeData: routeData, - child: SearchResultPage( - key: args.key, - searchTerm: args.searchTerm, - ), + child: const SearchPage(), ); }, SelectAdditionalUserForSharingRoute.name: (routeData) { @@ -1113,69 +1112,55 @@ class RecentlyAddedRoute extends PageRouteInfo { } /// generated route for -/// [SearchPage] -class SearchRoute extends PageRouteInfo { - SearchRoute({ +/// [SearchInputPage] +class SearchInputRoute extends PageRouteInfo { + SearchInputRoute({ Key? key, + SearchFilter? prefilter, List? children, }) : super( + SearchInputRoute.name, + args: SearchInputRouteArgs( + key: key, + prefilter: prefilter, + ), + initialChildren: children, + ); + + static const String name = 'SearchInputRoute'; + + static const PageInfo page = + PageInfo(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 { + const SearchRoute({List? children}) + : super( SearchRoute.name, - args: SearchRouteArgs(key: key), initialChildren: children, ); static const String name = 'SearchRoute'; - static const PageInfo page = PageInfo(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 { - SearchResultRoute({ - Key? key, - required String searchTerm, - List? children, - }) : super( - SearchResultRoute.name, - args: SearchResultRouteArgs( - key: key, - searchTerm: searchTerm, - ), - initialChildren: children, - ); - - static const String name = 'SearchResultRoute'; - - static const PageInfo page = - PageInfo(name); -} - -class SearchResultRouteArgs { - const SearchResultRouteArgs({ - this.key, - required this.searchTerm, - }); - - final Key? key; - - final String searchTerm; - - @override - String toString() { - return 'SearchResultRouteArgs{key: $key, searchTerm: $searchTerm}'; - } + static const PageInfo page = PageInfo(name); } /// generated route for diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index dafbedd31..afe87fb24 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -38,7 +38,7 @@ class TabNavigationObserver extends AutoRouterObserver { if (route.name == 'SearchRoute') { // Refresh Location State ref.invalidate(getCuratedLocationProvider); - ref.invalidate(getCuratedPeopleProvider); + ref.invalidate(getAllPeopleProvider); } if (route.name == 'SharingRoute') { diff --git a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart index 495f8f2f9..c9cc6c04a 100644 --- a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart +++ b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart @@ -43,6 +43,7 @@ class MultiselectGrid extends HookConsumerWidget { this.editEnabled = false, this.unarchive = false, this.unfavorite = false, + this.emptyIndicator, }); final ProviderListenable> renderListProvider; @@ -57,12 +58,12 @@ class MultiselectGrid extends HookConsumerWidget { final bool favoriteEnabled; final bool unfavorite; final bool editEnabled; - + final Widget? emptyIndicator; Widget buildDefaultLoadingIndicator() => const Center(child: ImmichLoadingIndicator()); Widget buildEmptyIndicator() => - const Center(child: Text("No assets to show")); + emptyIndicator ?? const Center(child: Text("No assets to show")); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/mobile/lib/shared/views/tab_controller_page.dart b/mobile/lib/shared/views/tab_controller_page.dart index 40850bdb4..40de493d0 100644 --- a/mobile/lib/shared/views/tab_controller_page.dart +++ b/mobile/lib/shared/views/tab_controller_page.dart @@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; -import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/tab.provider.dart'; @@ -53,10 +52,6 @@ class TabControllerPage extends HookConsumerWidget { // Scroll to top scrollToTopNotifierProvider.scrollToTop(); } - if (tabsRouter.activeIndex == 1 && index == 1) { - // Focus search - searchFocusNotifier.requestFocus(); - } HapticFeedback.selectionClick(); tabsRouter.setActiveIndex(index); @@ -111,10 +106,7 @@ class TabControllerPage extends HookConsumerWidget { // Scroll to top scrollToTopNotifierProvider.scrollToTop(); } - if (tabsRouter.activeIndex == 1 && index == 1) { - // Focus search - searchFocusNotifier.requestFocus(); - } + HapticFeedback.selectionClick(); tabsRouter.setActiveIndex(index); ref.read(tabProvider.notifier).state = TabEnum.values[index]; @@ -170,11 +162,11 @@ class TabControllerPage extends HookConsumerWidget { final multiselectEnabled = ref.watch(multiselectProvider); return AutoTabsRouter( - routes: [ - const HomeRoute(), + routes: const [ + HomeRoute(), SearchRoute(), - const SharingRoute(), - const LibraryRoute(), + SharingRoute(), + LibraryRoute(), ], duration: const Duration(milliseconds: 600), transitionBuilder: (context, child, animation) => FadeTransition( diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index e2ed6cd56..07fac00e4 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -33,6 +33,9 @@ final ThemeData base = ThemeData( final ThemeData immichLightTheme = ThemeData( useMaterial3: true, brightness: Brightness.light, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + ), primarySwatch: Colors.indigo, primaryColor: Colors.indigo, hintColor: Colors.indigo, @@ -158,6 +161,10 @@ final ThemeData immichDarkTheme = ThemeData( brightness: Brightness.dark, primarySwatch: Colors.indigo, primaryColor: immichDarkThemePrimaryColor, + colorScheme: ColorScheme.fromSeed( + seedColor: immichDarkThemePrimaryColor, + brightness: Brightness.dark, + ), scaffoldBackgroundColor: immichDarkBackgroundColor, hintColor: Colors.grey[600], fontFamily: 'Overpass', diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index feff2a66e..420d09660 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: "direct main" description: name: auto_route - sha256: "82f8df1d177416bc6b7a449127d0270ff1f0f633a91f2ceb7a85d4f07c3affa1" + sha256: eb33554581a0a4aa7e6da0f13a44291a55bf71359012f1d9feb41634ff908ff8 url: "https://pub.dev" source: hosted - version: "7.8.4" + version: "7.9.2" auto_route_generator: dependency: "direct dev" description: @@ -117,10 +117,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" + sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.9" build_runner_core: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: built_value - sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 + sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e url: "https://pub.dev" source: hosted - version: "8.9.0" + version: "8.9.1" cached_network_image: dependency: "direct main" description: @@ -309,34 +309,34 @@ packages: dependency: "direct dev" description: name: custom_lint - sha256: f89ff83efdba7c8996e86bb3bad0b759d58f9b19ae4d0e277a386ddd8b481217 + sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.4" custom_lint_builder: dependency: transitive description: name: custom_lint_builder - sha256: "3a14687fc71a5e2124a29722106f7b7e67dd5a6d58e33f2859650b46acff1d54" + sha256: d7dc41e709dde223806660268678be7993559e523eb3164e2a1425fd6f7615a9 url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.4" custom_lint_core: dependency: transitive description: name: custom_lint_core - sha256: "1e9128e095ad5e0973469bdaac1ead8bfc86c485954c23cf617299de5e6fa029" + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.3" dart_style: dependency: transitive description: name: dart_style - sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.6" dartx: dependency: transitive description: @@ -381,10 +381,10 @@ packages: dependency: "direct main" description: name: easy_localization - sha256: "9c86754b22aaa3e74e471635b25b33729f958dd6fb83df0ad6612948a7b231af" + sha256: c145aeb6584aedc7c862ab8c737c3277788f47488bfdf9bae0fe112bd0a4789c url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.0.5" easy_logger: dependency: transitive description: @@ -405,10 +405,10 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" file: dependency: transitive description: @@ -503,18 +503,18 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications - sha256: c18f1de98fe0bb9dd5ba91e1330d4febc8b6a7de6aae3ffe475ef423723e72f3 + sha256: "55b9b229307a10974b26296ff29f2e132256ba4bd74266939118eaefa941cb00" url: "https://pub.dev" source: hosted - version: "16.3.2" + version: "16.3.3" flutter_local_notifications_linux: dependency: transitive description: @@ -540,10 +540,10 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: "558f10070f03ee71f850a78f7136ab239a67636a294a44a06b6b7345178edb1e" + sha256: edf39bcf4d74aca1eb2c1e43c3e445fd9f494013df7f0da752fefe72020eedc0 url: "https://pub.dev" source: hosted - version: "2.3.10" + version: "2.4.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -556,10 +556,10 @@ packages: dependency: transitive description: name: flutter_riverpod - sha256: "4bce556b7ecbfea26109638d5237684538d4abc509d253e6c5c4c5733b360098" + sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.5.1" flutter_svg: dependency: "direct main" description: @@ -614,10 +614,10 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -635,18 +635,18 @@ packages: dependency: transitive description: name: geolocator_android - sha256: "136f1c97e1903366393bda514c5d9e98843418baea52899aa45edae9af8a5cd6" + sha256: f15d1536cd01b1399578f1da1eb5d566e7a718db6a3648f2c24d2e2f859f0692 url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.4" geolocator_apple: dependency: transitive description: name: geolocator_apple - sha256: "2f2d4ee16c4df269e93c0e382be075cc01d5db6703c3196e4af20a634fe49ef4" + sha256: bc2aca02423ad429cb0556121f56e60360a2b7d694c8570301d06ea0c00732fd url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.3.7" geolocator_platform_interface: dependency: transitive description: @@ -667,10 +667,10 @@ packages: dependency: transitive description: name: geolocator_windows - sha256: a92fae29779d5c6dc60e8411302f5221ade464968fe80a36d330e80a71f087af + sha256: "53da08937d07c24b0d9952eb57a3b474e29aae2abf9dd717f7e1230995f13f0e" url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.2.3" glob: dependency: transitive description: @@ -691,10 +691,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "758b07eba336e3cbacbd81dba481f2228a14102083fdde07045e8514e8054c49" + sha256: "45b2030a18bcd6dbd680c2c91bc3b33e3fe7c323e3acb5ecec93a613e2fbaa8a" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.5.1" hotreloader: dependency: transitive description: @@ -755,10 +755,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "39f2bfe497e495450c81abcd44b62f56c2a36a37a175da7d137b4454977b51b1" + sha256: "42c098e7fb6334746be37cdc30369ade356ed4f14d48b7a0313f95a9159f4321" url: "https://pub.dev" source: hosted - version: "0.8.9+3" + version: "0.8.9+5" image_picker_for_web: dependency: transitive description: @@ -771,10 +771,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: fadafce49e8569257a0cad56d24438a6fa1f0cbd7ee0af9b631f7492818a4ca3 + sha256: "917a5cadd67d052554cfb258595e54217de53fac5b52939426e26319a02e6297" url: "https://pub.dev" source: hosted - version: "0.8.9+1" + version: "0.8.9+2" image_picker_linux: dependency: transitive description: @@ -795,10 +795,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: fa4e815e6fcada50e35718727d83ba1c92f1edf95c0b4436554cec301b56233b + sha256: "3d2c323daea9d60608f1caf30be32a938916f4975434b8352e6f73dae496da38" url: "https://pub.dev" source: hosted - version: "2.9.3" + version: "2.9.4" image_picker_windows: dependency: transitive description: @@ -922,7 +922,7 @@ packages: description: path: maplibre_gl_platform_interface ref: main - resolved-ref: "3cf0abb051849ca3f14e6aa19d2261ad18f22ce1" + resolved-ref: ec5a29dea08e8c2fadf9c55bd5bc500ef5b2a685 url: "https://github.com/maplibre/flutter-maplibre-gl.git" source: git version: "0.18.0" @@ -931,7 +931,7 @@ packages: description: path: maplibre_gl_web ref: main - resolved-ref: "3cf0abb051849ca3f14e6aa19d2261ad18f22ce1" + resolved-ref: ec5a29dea08e8c2fadf9c55bd5bc500ef5b2a685 url: "https://github.com/maplibre/flutter-maplibre-gl.git" source: git version: "0.18.0" @@ -1106,10 +1106,10 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "74e962b7fad7ff75959161bb2c0ad8fe7f2568ee82621c9c2660b751146bfe44" + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" url: "https://pub.dev" source: hosted - version: "11.3.0" + version: "11.3.1" permission_handler_android: dependency: transitive description: @@ -1122,10 +1122,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: bdafc6db74253abb63907f4e357302e6bb786ab41465e8635f362ee71fd8707b + sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 url: "https://pub.dev" source: hosted - version: "9.4.0" + version: "9.4.4" permission_handler_html: dependency: transitive description: @@ -1138,10 +1138,10 @@ packages: dependency: transitive description: name: permission_handler_platform_interface - sha256: "23dfba8447c076ab5be3dee9ceb66aad345c4a648f0cac292c77b1eb0e800b78" + sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.2.1" permission_handler_windows: dependency: transitive description: @@ -1162,18 +1162,18 @@ packages: dependency: "direct main" description: name: photo_manager - sha256: "8cf79918f6de9843b394a1670fe1aec54ebcac852b4b4c9ef88211894547dc61" + sha256: df594f989f0c31cdb3ed48f3d49cb9ffadf11cc3700d2c3460b1912c93432621 url: "https://pub.dev" source: hosted - version: "3.0.0-dev.5" + version: "3.0.0" photo_manager_image_provider: dependency: "direct main" description: name: photo_manager_image_provider - sha256: c187f60c3fdbe5630735d9a0bccbb071397ec03dcb1ba6085c29c8adece798a0 + sha256: "38ef1023dc11de3a8669f16e7c981673b3c5cfee715d17120f4b87daa2cdd0af" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" platform: dependency: transitive description: @@ -1218,10 +1218,10 @@ packages: dependency: transitive description: name: provider - sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "6.1.2" pub_semver: dependency: transitive description: @@ -1242,10 +1242,10 @@ packages: dependency: transitive description: name: riverpod - sha256: "548e2192eb7aeb826eb89387f814edb76594f3363e2c0bb99dd733d795ba3589" + sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.1" riverpod_analyzer_utils: dependency: transitive description: @@ -1258,26 +1258,26 @@ packages: dependency: "direct main" description: name: riverpod_annotation - sha256: "77e5d51afa4fa3e67903fb8746f33d368728d7051a0b6c292bcee60aeba46d95" + sha256: e5e796c0eba4030c704e9dae1b834a6541814963292839dcf9638d53eba84f5c url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" riverpod_generator: dependency: "direct dev" description: name: riverpod_generator - sha256: "359068f04879347ae4edbe66c81cc95f83fa1743806d1a0c86e55dd3c33ebb32" + sha256: d451608bf17a372025fc36058863737636625dfdb7e3cbf6142e0dfeb366ab22 url: "https://pub.dev" source: hosted - version: "2.3.11" + version: "2.4.0" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: e9bbd02e9e89e18eecb183bbca556d7b523a0669024da9b8167c08903f442937 + sha256: "3c67c14ccd16f0c9d53e35ef70d06cd9d072e2fb14557326886bbde903b230a5" url: "https://pub.dev" source: hosted - version: "2.3.9" + version: "2.3.10" rxdart: dependency: transitive description: @@ -1306,10 +1306,10 @@ packages: dependency: transitive description: name: share_plus_platform_interface - sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 + sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.0" shared_preferences: dependency: transitive description: @@ -1439,10 +1439,10 @@ packages: dependency: transitive description: name: sqflite_common - sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5" + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" stack_trace: dependency: transitive description: @@ -1567,10 +1567,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c + sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.2.5" url_launcher_android: dependency: transitive description: @@ -1583,10 +1583,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.2.5" url_launcher_linux: dependency: transitive description: @@ -1671,10 +1671,10 @@ packages: dependency: "direct main" description: name: video_player - sha256: fbf28ce8bcfe709ad91b5789166c832cb7a684d14f571a81891858fefb5bb1c2 + sha256: afc65f4b8bcb2c188f64a591f84fb471f4f2e19fc607c65fd8d2f8fedb3dec23 url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.8.3" video_player_android: dependency: transitive description: @@ -1703,10 +1703,10 @@ packages: dependency: transitive description: name: video_player_web - sha256: "34beb3a07d4331a24f7e7b2f75b8e2b103289038e07e65529699a671b6a6e2cb" + sha256: "8e9cb7fe94e49490e67bbc15149691792b58a0ade31b32e3f3688d104a0e057b" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" vm_service: dependency: transitive description: @@ -1727,10 +1727,10 @@ packages: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "40fabed5da06caff0796dc638e1f07ee395fb18801fbff3255a2372db2d80385" + sha256: "582f2f7aecc7376332d961a0dd1efa9378ce117657e0ade55d9ff72699a55e82" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" watcher: dependency: transitive description: @@ -1743,18 +1743,18 @@ packages: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.4.2" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "939ab60734a4f8fa95feacb55804fa278de28bdeef38e616dc08e44a84adea23" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.3" webdriver: dependency: transitive description: @@ -1767,10 +1767,10 @@ packages: dependency: transitive description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.4.0" win32_registry: dependency: transitive description: @@ -1813,4 +1813,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.16.0" + flutter: ">=3.19.0" 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) { 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 })