forked from Cutlery/immich
Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job
This commit is contained in:
commit
ee0fedf061
54
docs/docs/administration/postgres-standalone.md
Normal file
54
docs/docs/administration/postgres-standalone.md
Normal file
@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
|
||||
By default, Immich expects superuser permission on the Postgres database and requires certain extensions to be installed. This guide outlines the steps required to prepare a pre-existing Postgres server to be used by Immich.
|
||||
|
||||
:::tip
|
||||
Running with a pre-existing Postgres server can unlock powerful administrative features, including logical replication, data page checksums, and streaming write-ahead log backups using programs like pgBackRest or Barman.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You must install pgvecto.rs using their [instructions](https://docs.pgvecto.rs/getting-started/installation.html). After installation, add `shared_preload_libraries = 'vectors.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vectors.so'`.
|
||||
|
||||
:::note
|
||||
Make sure the installed version of pgvecto.rs is compatible with your version of Immich. For example, if your Immich version uses the dedicated database image `tensorchord/pgvecto-rs:pg14-v0.2.1`, you must install pgvecto.rs `>= 0.2.1, < 0.3.0`.
|
||||
:::
|
||||
|
||||
## Specifying the connection URL
|
||||
|
||||
You can connect to your pre-existing Postgres server by setting the `DB_URL` environment variable in the `.env` file.
|
||||
|
||||
```
|
||||
DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename'
|
||||
|
||||
# require a SSL connection to Postgres
|
||||
# DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename?sslmode=require'
|
||||
|
||||
# require a SSL connection, but don't enforce checking the certificate name
|
||||
# DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename?sslmode=require&sslmode=no-verify'
|
||||
```
|
||||
|
||||
## Without superuser permissions
|
||||
|
||||
### Initial installation
|
||||
|
||||
Immich can run without superuser permissions by following the below instructions at the `psql` prompt to prepare the database.
|
||||
|
||||
```sql title="Set up Postgres for Immich"
|
||||
CREATE DATABASE <immichdatabasename>;
|
||||
\c <immichdatabasename>
|
||||
BEGIN;
|
||||
ALTER DATABASE <immichdatabasename> OWNER TO <immichdbusername>;
|
||||
CREATE EXTENSION vectors;
|
||||
CREATE EXTENSION earthdistance CASCADE;
|
||||
ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors;
|
||||
GRANT USAGE ON SCHEMA vectors TO <immichdbusername>;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA vectors GRANT SELECT ON TABLES TO <immichdbusername>;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### Updating pgvecto.rs
|
||||
|
||||
When installing a new version of pgvecto.rs, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vectors UPDATE;`.
|
@ -1,29 +1,41 @@
|
||||
# Reverse Proxy
|
||||
|
||||
Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
|
||||
Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
|
||||
|
||||
:::note
|
||||
The Repair page can take a long time to load. To avoid server timeouts or errors, we recommend specifying a timeout of at least 10 minutes on your proxy server.
|
||||
:::
|
||||
|
||||
### Nginx example config
|
||||
|
||||
Below is an example config for nginx. Make sure to include `client_max_body_size 50000M;` also in a `http` block in `/etc/nginx/nginx.conf`.
|
||||
Below is an example config for nginx. Make sure to set `public_url` to the front-facing URL of your instance, and `backend_url` to the path of the Immich server.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name <snip>
|
||||
server_name <public_url>;
|
||||
|
||||
# allow large file uploads
|
||||
client_max_body_size 50000M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://<snip>:2283;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Set headers
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# http://nginx.org/en/docs/http/websocket.html
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_redirect off;
|
||||
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_redirect off;
|
||||
|
||||
# set timeout
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
send_timeout 600s;
|
||||
|
||||
location / {
|
||||
proxy_pass http://<backend_url>:2283;
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -42,15 +54,13 @@ immich.example.org {
|
||||
|
||||
Below is an example config for Apache2 site configuration.
|
||||
|
||||
```
|
||||
```ApacheConf
|
||||
<VirtualHost *:80>
|
||||
ServerName <snip>
|
||||
ProxyRequests Off
|
||||
# set timeout in seconds
|
||||
ProxyPass / http://127.0.0.1:2283/ timeout=600 upgrade=websocket
|
||||
ProxyPassReverse / http://127.0.0.1:2283/
|
||||
ProxyPreserveHost On
|
||||
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
**timeout:** is measured in seconds, and it is particularly useful when long operations are triggered (i.e. Repair), so the server doesn't return an error.
|
||||
|
@ -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.
|
||||
|
@ -56,4 +56,4 @@ A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn
|
||||
### Cons
|
||||
|
||||
- Complex configuration
|
||||
- Depending on your configuration, both the Immich web interface and API may be exposed to the internet. Immich is under very active developement and the existence of severe security vulnerabilities cannot be ruled out.
|
||||
- Depending on your configuration, both the Immich web interface and API may be exposed to the internet. Immich is under very active development and the existence of severe security vulnerabilities cannot be ruled out.
|
||||
|
@ -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`<sup>\*1</sup> | Toggle all metrics (one of [`true`, `false`]) | | server, microservices |
|
||||
| `IMMICH_API_METRICS` | Toggle metrics for endpoints and response times (one of [`true`, `false`]) | | server, microservices |
|
||||
| `IMMICH_HOST_METRICS` | Toggle metrics for CPU and memory utilization for host and process (one of [`true`, `false`]) | | server, microservices |
|
||||
| `IMMICH_IO_METRICS` | Toggle metrics for database queries, image processing, etc. (one of [`true`, `false`]) | | server, microservices |
|
||||
| `IMMICH_JOB_METRICS` | Toggle metrics for jobs and queues (one of [`true`, `false`]) | | server, microservices |
|
||||
|
||||
\*1: Overridden for a metric group when its corresponding environmental variable is set.
|
||||
|
||||
## Docker Secrets
|
||||
|
||||
The following variables support the use of [Docker secrets](https://docs.docker.com/engine/swarm/secrets/) for additional security.
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets } from '@immich/sdk';
|
||||
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
@ -7,7 +7,6 @@ import { errorDto } from 'src/responses';
|
||||
import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const today = DateTime.now();
|
||||
|
||||
describe('/search', () => {
|
||||
@ -19,7 +18,7 @@ describe('/search', () => {
|
||||
let assetCyclamen: AssetFileUploadResponseDto;
|
||||
let assetNotocactus: AssetFileUploadResponseDto;
|
||||
let assetSilver: AssetFileUploadResponseDto;
|
||||
// let assetDensity: AssetFileUploadResponseDto;
|
||||
let assetDensity: AssetFileUploadResponseDto;
|
||||
// let assetPhiladelphia: AssetFileUploadResponseDto;
|
||||
// let assetOrychophragmus: AssetFileUploadResponseDto;
|
||||
// let assetRidge: AssetFileUploadResponseDto;
|
||||
@ -79,6 +78,37 @@ describe('/search', () => {
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
}
|
||||
|
||||
// note: the coordinates here are not the actual coordinates of the images and are random for most of them
|
||||
const cities = [
|
||||
{ latitude: 48.853_41, longitude: 2.3488 }, // paris
|
||||
{ latitude: 63.0695, longitude: -151.0074 }, // denali
|
||||
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin
|
||||
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
|
||||
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
|
||||
{ latitude: 5.556_02, longitude: -0.1969 }, // accra
|
||||
{ latitude: 37.544_270_6, longitude: -4.727_752_8 }, // andalusia
|
||||
{ latitude: 23.133_02, longitude: -82.383_04 }, // havana
|
||||
{ latitude: 41.694_11, longitude: 44.833_68 }, // tbilisi
|
||||
{ latitude: 31.222_22, longitude: 121.458_06 }, // shanghai
|
||||
{ latitude: 47.040_57, longitude: 9.068_04 }, // glarus
|
||||
{ latitude: 38.9711, longitude: -109.7137 }, // thompson springs
|
||||
{ latitude: 40.714_27, longitude: -74.005_97 }, // new york
|
||||
{ latitude: 32.771_52, longitude: -89.116_73 }, // philadelphia
|
||||
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
|
||||
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
|
||||
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
|
||||
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
|
||||
];
|
||||
|
||||
const updates = assets.map((asset, i) =>
|
||||
updateAsset({ id: asset.id, updateAssetDto: cities[i] }, { headers: asBearerAuth(admin.accessToken) }),
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
for (const asset of assets) {
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
||||
}
|
||||
|
||||
[
|
||||
assetFalcon,
|
||||
assetDenali,
|
||||
@ -92,7 +122,7 @@ describe('/search', () => {
|
||||
assetOneJpg5,
|
||||
assetGlarus,
|
||||
assetSprings,
|
||||
// assetDensity,
|
||||
assetDensity,
|
||||
// assetPhiladelphia,
|
||||
// assetOrychophragmus,
|
||||
// assetRidge,
|
||||
@ -106,7 +136,7 @@ describe('/search', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await utils.disconnectWebsocket(websocket);
|
||||
utils.disconnectWebsocket(websocket);
|
||||
});
|
||||
|
||||
describe('POST /search/metadata', () => {
|
||||
@ -298,15 +328,15 @@ describe('/search', () => {
|
||||
},
|
||||
{
|
||||
should: 'should search by city',
|
||||
deferred: () => ({ dto: { city: 'Ralston' }, assets: [assetHeic] }),
|
||||
deferred: () => ({ dto: { city: 'Accra' }, assets: [assetHeic] }),
|
||||
},
|
||||
{
|
||||
should: 'should search by state',
|
||||
deferred: () => ({ dto: { state: 'Douglas County, Nebraska' }, assets: [assetHeic] }),
|
||||
deferred: () => ({ dto: { state: 'New York' }, assets: [assetDensity] }),
|
||||
},
|
||||
{
|
||||
should: 'should search by country',
|
||||
deferred: () => ({ dto: { country: 'United States of America' }, assets: [assetHeic] }),
|
||||
deferred: () => ({ dto: { country: 'France' }, assets: [assetFalcon] }),
|
||||
},
|
||||
{
|
||||
should: 'should search by make',
|
||||
@ -370,13 +400,44 @@ describe('/search', () => {
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get places', async () => {
|
||||
it('should get relevant places', async () => {
|
||||
const name = 'Paris';
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/search/places?name=Paris')
|
||||
.get(`/search/places?name=${name}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
if (Array.isArray(body)) {
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
expect(body[0].name).toEqual(name);
|
||||
expect(body[0].admin2name).toEqual(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/cities', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/search/cities');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get all cities', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/search/cities')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
if (Array.isArray(body)) {
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
const assetsWithCity = body.filter((asset) => !!asset.exifInfo?.city);
|
||||
expect(assetsWithCity.length).toEqual(body.length);
|
||||
const cities = new Set(assetsWithCity.map((asset) => asset.exifInfo.city));
|
||||
expect(cities.size).toEqual(body.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -391,7 +452,21 @@ describe('/search', () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/search/suggestions?type=country')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(body).toEqual(['United States of America']);
|
||||
expect(body).toEqual([
|
||||
'Cuba',
|
||||
'France',
|
||||
'Georgia',
|
||||
'Germany',
|
||||
'Ghana',
|
||||
'Japan',
|
||||
'Morocco',
|
||||
"People's Republic of China",
|
||||
'Russian Federation',
|
||||
'Singapore',
|
||||
'Spain',
|
||||
'Switzerland',
|
||||
'United States of America',
|
||||
]);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
@ -399,7 +474,23 @@ describe('/search', () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/search/suggestions?type=state')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(body).toEqual(['Douglas County, Nebraska', 'Mesa County, Colorado']);
|
||||
expect(body).toEqual([
|
||||
'Accra, Greater Accra',
|
||||
'Berlin',
|
||||
'Glarus, Glarus',
|
||||
'Havana',
|
||||
'Marrakech, Marrakesh-Safi',
|
||||
'Mesa County, Colorado',
|
||||
'Neshoba County, Mississippi',
|
||||
'New York',
|
||||
'Page County, Virginia',
|
||||
'Paris, Île-de-France',
|
||||
'Province of Córdoba, Andalusia',
|
||||
'Shanghai Municipality, Shanghai',
|
||||
'St.-Petersburg',
|
||||
'Tbilisi',
|
||||
'Tokyo',
|
||||
]);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
@ -407,7 +498,24 @@ describe('/search', () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/search/suggestions?type=city')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(body).toEqual(['Palisade', 'Ralston']);
|
||||
expect(body).toEqual([
|
||||
'Accra',
|
||||
'Berlin',
|
||||
'Glarus',
|
||||
'Havana',
|
||||
'Marrakesh',
|
||||
'Montalbán de Córdoba',
|
||||
'New York City',
|
||||
'Palisade',
|
||||
'Paris',
|
||||
'Philadelphia',
|
||||
'Saint Petersburg',
|
||||
'Shanghai',
|
||||
'Singapore',
|
||||
'Stanley',
|
||||
'Tbilisi',
|
||||
'Tokyo',
|
||||
]);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
|
@ -39,7 +39,7 @@ import { makeRandomImage } from 'src/generators';
|
||||
import request from 'supertest';
|
||||
|
||||
type CliResponse = { stdout: string; stderr: string; exitCode: number | null };
|
||||
type EventType = 'assetUpload' | 'assetDelete' | 'userDelete';
|
||||
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete';
|
||||
type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
|
||||
type AdminSetupOptions = { onboarding?: boolean };
|
||||
type AssetData = { bytes?: Buffer; filename: string };
|
||||
@ -82,6 +82,7 @@ let client: pg.Client | null = null;
|
||||
|
||||
const events: Record<EventType, Set<string>> = {
|
||||
assetUpload: new Set<string>(),
|
||||
assetUpdate: new Set<string>(),
|
||||
assetDelete: new Set<string>(),
|
||||
userDelete: new Set<string>(),
|
||||
};
|
||||
@ -185,6 +186,7 @@ export const utils = {
|
||||
websocket
|
||||
.on('connect', () => resolve(websocket))
|
||||
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id }))
|
||||
.on('on_asset_update', (data: AssetResponseDto) => onEvent({ event: 'assetUpdate', id: data.id }))
|
||||
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
|
||||
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))
|
||||
.connect();
|
||||
|
@ -1,6 +1,6 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:991e20a11120277e977cadbc104e7a9b196a68a346597879821b19034285a403 as builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:e2ed446c899827ed992f8a5a8875fa0853fcab32581e61418b650322061aa3c4 as builder-cpu
|
||||
|
||||
FROM openvino/ubuntu22_runtime:2023.3.0@sha256:176646df619032ea6c10faf842867119c393e7497b7f88b5e307e932a0fd5aa8 as builder-openvino
|
||||
USER root
|
||||
@ -36,7 +36,7 @@ RUN python3 -m venv /opt/venv
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:a2eb07f336e4f194358382611b4fea136c632b40baa6314cb27a366deeaf0144 as prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:90f8795536170fd08236d2ceb74fe7065dbf74f738d8b84bfbf263656654dc9b as prod-cpu
|
||||
|
||||
FROM openvino/ubuntu22_runtime:2023.3.0@sha256:176646df619032ea6c10faf842867119c393e7497b7f88b5e307e932a0fd5aa8 as prod-openvino
|
||||
USER root
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:881dbb68d115182b2c12e7e77dc54ea5005fd4e0123ca009d822adb5b0631785 as builder
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:3624db3aee11d2f3f00d25f691aaaf8834b8bc4ec1b340dcdb48ef37281ea604 as builder
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
TRANSFORMERS_CACHE=/cache \
|
||||
|
79
machine-learning/poetry.lock
generated
79
machine-learning/poetry.lock
generated
@ -1274,13 +1274,13 @@ socks = ["socksio (==1.*)"]
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "0.21.4"
|
||||
version = "0.22.2"
|
||||
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
files = [
|
||||
{file = "huggingface_hub-0.21.4-py3-none-any.whl", hash = "sha256:df37c2c37fc6c82163cdd8a67ede261687d80d1e262526d6c0ce73b6b3630a7b"},
|
||||
{file = "huggingface_hub-0.21.4.tar.gz", hash = "sha256:e1f4968c93726565a80edf6dc309763c7b546d0cfe79aa221206034d50155531"},
|
||||
{file = "huggingface_hub-0.22.2-py3-none-any.whl", hash = "sha256:3429e25f38ccb834d310804a3b711e7e4953db5a9e420cc147a5e194ca90fd17"},
|
||||
{file = "huggingface_hub-0.22.2.tar.gz", hash = "sha256:32e9a9a6843c92f253ff9ca16b9985def4d80a93fb357af5353f770ef74a81be"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1293,15 +1293,16 @@ tqdm = ">=4.42.1"
|
||||
typing-extensions = ">=3.7.4.3"
|
||||
|
||||
[package.extras]
|
||||
all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "mypy (==1.5.1)", "numpy", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.1.3)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
|
||||
all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.3.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
|
||||
cli = ["InquirerPy (==0.3.4)"]
|
||||
dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "mypy (==1.5.1)", "numpy", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.1.3)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
|
||||
dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.3.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
|
||||
fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"]
|
||||
hf-transfer = ["hf-transfer (>=0.1.4)"]
|
||||
inference = ["aiohttp", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)"]
|
||||
quality = ["mypy (==1.5.1)", "ruff (>=0.1.3)"]
|
||||
inference = ["aiohttp", "minijinja (>=1.0)"]
|
||||
quality = ["mypy (==1.5.1)", "ruff (>=0.3.0)"]
|
||||
tensorflow = ["graphviz", "pydot", "tensorflow"]
|
||||
testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "numpy", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"]
|
||||
tensorflow-testing = ["keras (<3.0)", "tensorflow"]
|
||||
testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"]
|
||||
torch = ["safetensors", "torch"]
|
||||
typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"]
|
||||
|
||||
@ -1567,13 +1568,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "locust"
|
||||
version = "2.24.0"
|
||||
version = "2.24.1"
|
||||
description = "Developer friendly load testing framework"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "locust-2.24.0-py3-none-any.whl", hash = "sha256:1b6b878b4fd0108fec956120815e69775d2616c8f4d1e9f365c222a7a5c17d9a"},
|
||||
{file = "locust-2.24.0.tar.gz", hash = "sha256:6cffa378d995244a7472af6be1d6139331f19aee44e907deee73e0281252804d"},
|
||||
{file = "locust-2.24.1-py3-none-any.whl", hash = "sha256:7f6ed4dc289aad66c304582e6d25e4de5d7c3b175b580332442ab2be35b9d916"},
|
||||
{file = "locust-2.24.1.tar.gz", hash = "sha256:094161d44d94839bf1120fd7898b7abb9c143833743ba7c096beb470a236b9a7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -2495,13 +2496,13 @@ testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygm
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "0.23.5.post1"
|
||||
version = "0.23.6"
|
||||
description = "Pytest support for asyncio"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"},
|
||||
{file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"},
|
||||
{file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"},
|
||||
{file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -2531,17 +2532,17 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.12.0"
|
||||
version = "3.14.0"
|
||||
description = "Thin-wrapper around the mock package for easier use with pytest"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"},
|
||||
{file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"},
|
||||
{file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"},
|
||||
{file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=5.0"
|
||||
pytest = ">=6.2.5"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "pytest-asyncio", "tox"]
|
||||
@ -2844,28 +2845,28 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:973a0e388b7bc2e9148c7f9be8b8c6ae7471b9be37e1cc732f8f44a6f6d7720d"},
|
||||
{file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfa60d23269d6e2031129b053fdb4e5a7b0637fc6c9c0586737b962b2f834493"},
|
||||
{file = "ruff-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eca7ff7a47043cf6ce5c7f45f603b09121a7cc047447744b029d1b719278eb5"},
|
||||
{file = "ruff-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7d3f6762217c1da954de24b4a1a70515630d29f71e268ec5000afe81377642d"},
|
||||
{file = "ruff-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c19e8598916d9c6f5a5437671f55ee93c212a2c4c569605dc3842b6820386"},
|
||||
{file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a6cbf216b69c7090f0fe4669501a27326c34e119068c1494f35aaf4cc683778"},
|
||||
{file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352e95ead6964974b234e16ba8a66dad102ec7bf8ac064a23f95371d8b198aab"},
|
||||
{file = "ruff-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6ab88c81c4040a817aa432484e838aaddf8bfd7ca70e4e615482757acb64f8"},
|
||||
{file = "ruff-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79bca3a03a759cc773fca69e0bdeac8abd1c13c31b798d5bb3c9da4a03144a9f"},
|
||||
{file = "ruff-0.3.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2700a804d5336bcffe063fd789ca2c7b02b552d2e323a336700abb8ae9e6a3f8"},
|
||||
{file = "ruff-0.3.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd66469f1a18fdb9d32e22b79f486223052ddf057dc56dea0caaf1a47bdfaf4e"},
|
||||
{file = "ruff-0.3.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45817af234605525cdf6317005923bf532514e1ea3d9270acf61ca2440691376"},
|
||||
{file = "ruff-0.3.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0da458989ce0159555ef224d5b7c24d3d2e4bf4c300b85467b08c3261c6bc6a8"},
|
||||
{file = "ruff-0.3.3-py3-none-win32.whl", hash = "sha256:f2831ec6a580a97f1ea82ea1eda0401c3cdf512cf2045fa3c85e8ef109e87de0"},
|
||||
{file = "ruff-0.3.3-py3-none-win_amd64.whl", hash = "sha256:be90bcae57c24d9f9d023b12d627e958eb55f595428bafcb7fec0791ad25ddfc"},
|
||||
{file = "ruff-0.3.3-py3-none-win_arm64.whl", hash = "sha256:0171aab5fecdc54383993389710a3d1227f2da124d76a2784a7098e818f92d61"},
|
||||
{file = "ruff-0.3.3.tar.gz", hash = "sha256:38671be06f57a2f8aba957d9f701ea889aa5736be806f18c0cd03d6ff0cbca8d"},
|
||||
{file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4"},
|
||||
{file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91"},
|
||||
{file = "ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854"},
|
||||
{file = "ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7"},
|
||||
{file = "ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365"},
|
||||
{file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369"},
|
||||
{file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c"},
|
||||
{file = "ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9"},
|
||||
{file = "ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50"},
|
||||
{file = "ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed"},
|
||||
{file = "ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488"},
|
||||
{file = "ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e"},
|
||||
{file = "ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378"},
|
||||
{file = "ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102"},
|
||||
{file = "ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6"},
|
||||
{file = "ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232"},
|
||||
{file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3289,13 +3290,13 @@ zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.28.0"
|
||||
version = "0.29.0"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "uvicorn-0.28.0-py3-none-any.whl", hash = "sha256:6623abbbe6176204a4226e67607b4d52cc60ff62cda0ff177613645cefa2ece1"},
|
||||
{file = "uvicorn-0.28.0.tar.gz", hash = "sha256:cab4473b5d1eaeb5a0f6375ac4bc85007ffc75c3cc1768816d9e5d589857b067"},
|
||||
{file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"},
|
||||
{file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
@ -283,6 +283,10 @@
|
||||
"map_settings_only_show_favorites": "Show Favorite Only",
|
||||
"map_settings_theme_settings": "Map Theme",
|
||||
"map_zoom_to_see_photos": "Zoom out to see photos",
|
||||
"memories_all_caught_up": "All caught up",
|
||||
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
|
||||
"memories_start_over": "Start Over",
|
||||
"memories_swipe_to_close": "Swipe up to close",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"motion_photos_page_title": "Motion Photos",
|
||||
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
|
||||
@ -482,4 +486,4 @@
|
||||
"viewer_remove_from_stack": "Remove from Stack",
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
"viewer_unstack": "Un-Stack"
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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<void> _updateAssets(
|
||||
int albumId, {
|
||||
Iterable<Asset> add = const [],
|
||||
Iterable<Asset> remove = const [],
|
||||
}) {
|
||||
return _db.writeTxn(() async {
|
||||
final album = await _db.albums.get(albumId);
|
||||
if (album == null) return;
|
||||
await album.assets.update(link: add, unlink: remove);
|
||||
album.startDate =
|
||||
await album.assets.filter().fileCreatedAtProperty().min();
|
||||
album.endDate = await album.assets.filter().fileCreatedAtProperty().max();
|
||||
album.lastModifiedAssetTimestamp =
|
||||
await album.assets.filter().updatedAtProperty().max();
|
||||
await _db.albums.put(album);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> addAdditionalUserToAlbum(
|
||||
List<String> 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<Asset> 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<bool> removeUserFromAlbum(
|
||||
@ -413,7 +426,7 @@ class AlbumService {
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||
debugPrint("Error changeTitleAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@ -55,27 +56,27 @@ class _MemoryEpilogueState extends State<MemoryEpilogue>
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
Text(
|
||||
'All caught up',
|
||||
"memories_all_caught_up",
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
).tr(),
|
||||
const SizedBox(height: 16.0),
|
||||
Text(
|
||||
'Check back tomorrow for more memories',
|
||||
"memories_check_back_tomorrow",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
).tr(),
|
||||
const SizedBox(height: 16.0),
|
||||
TextButton(
|
||||
onPressed: widget.onStartOver,
|
||||
child: Text(
|
||||
'Start Over',
|
||||
"memories_start_over",
|
||||
style: context.textTheme.displayMedium?.copyWith(
|
||||
color: immichDarkThemePrimaryColor,
|
||||
),
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -106,11 +107,11 @@ class _MemoryEpilogueState extends State<MemoryEpilogue>
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Swipe up to close',
|
||||
"memories_swipe_to_close",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -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<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'label': label,
|
||||
'id': id,
|
||||
};
|
||||
}
|
||||
|
||||
factory CuratedContent.fromMap(Map<String, dynamic> map) {
|
||||
return CuratedContent(
|
||||
label: map['label'] as String,
|
||||
id: map['id'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CuratedContent.fromJson(String source) =>
|
||||
CuratedContent.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'CuratedContent(label: $label, id: $id)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant CuratedContent other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.label == label && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => label.hashCode ^ id.hashCode;
|
||||
}
|
||||
|
310
mobile/lib/modules/search/models/search_filter.dart
Normal file
310
mobile/lib/modules/search/models/search_filter.dart
Normal file
@ -0,0 +1,310 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SearchLocationFilter {
|
||||
String? country;
|
||||
String? state;
|
||||
String? city;
|
||||
SearchLocationFilter({
|
||||
this.country,
|
||||
this.state,
|
||||
this.city,
|
||||
});
|
||||
|
||||
SearchLocationFilter copyWith({
|
||||
String? country,
|
||||
String? state,
|
||||
String? city,
|
||||
}) {
|
||||
return SearchLocationFilter(
|
||||
country: country ?? this.country,
|
||||
state: state ?? this.state,
|
||||
city: city ?? this.city,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'country': country,
|
||||
'state': state,
|
||||
'city': city,
|
||||
};
|
||||
}
|
||||
|
||||
factory SearchLocationFilter.fromMap(Map<String, dynamic> map) {
|
||||
return SearchLocationFilter(
|
||||
country: map['country'] != null ? map['country'] as String : null,
|
||||
state: map['state'] != null ? map['state'] as String : null,
|
||||
city: map['city'] != null ? map['city'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchLocationFilter.fromJson(String source) =>
|
||||
SearchLocationFilter.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SearchLocationFilter(country: $country, state: $state, city: $city)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchLocationFilter other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.country == country &&
|
||||
other.state == state &&
|
||||
other.city == city;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => country.hashCode ^ state.hashCode ^ city.hashCode;
|
||||
}
|
||||
|
||||
class SearchCameraFilter {
|
||||
String? make;
|
||||
String? model;
|
||||
SearchCameraFilter({
|
||||
this.make,
|
||||
this.model,
|
||||
});
|
||||
|
||||
SearchCameraFilter copyWith({
|
||||
String? make,
|
||||
String? model,
|
||||
}) {
|
||||
return SearchCameraFilter(
|
||||
make: make ?? this.make,
|
||||
model: model ?? this.model,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'make': make,
|
||||
'model': model,
|
||||
};
|
||||
}
|
||||
|
||||
factory SearchCameraFilter.fromMap(Map<String, dynamic> map) {
|
||||
return SearchCameraFilter(
|
||||
make: map['make'] != null ? map['make'] as String : null,
|
||||
model: map['model'] != null ? map['model'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchCameraFilter.fromJson(String source) =>
|
||||
SearchCameraFilter.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'SearchCameraFilter(make: $make, model: $model)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchCameraFilter other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.make == make && other.model == model;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => make.hashCode ^ model.hashCode;
|
||||
}
|
||||
|
||||
class SearchDateFilter {
|
||||
DateTime? takenBefore;
|
||||
DateTime? takenAfter;
|
||||
SearchDateFilter({
|
||||
this.takenBefore,
|
||||
this.takenAfter,
|
||||
});
|
||||
|
||||
SearchDateFilter copyWith({
|
||||
DateTime? takenBefore,
|
||||
DateTime? takenAfter,
|
||||
}) {
|
||||
return SearchDateFilter(
|
||||
takenBefore: takenBefore ?? this.takenBefore,
|
||||
takenAfter: takenAfter ?? this.takenAfter,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'takenBefore': takenBefore?.millisecondsSinceEpoch,
|
||||
'takenAfter': takenAfter?.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
factory SearchDateFilter.fromMap(Map<String, dynamic> map) {
|
||||
return SearchDateFilter(
|
||||
takenBefore: map['takenBefore'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['takenBefore'] as int)
|
||||
: null,
|
||||
takenAfter: map['takenAfter'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['takenAfter'] as int)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchDateFilter.fromJson(String source) =>
|
||||
SearchDateFilter.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SearchDateFilter(takenBefore: $takenBefore, takenAfter: $takenAfter)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchDateFilter other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.takenBefore == takenBefore && other.takenAfter == takenAfter;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode;
|
||||
}
|
||||
|
||||
class SearchDisplayFilters {
|
||||
bool isNotInAlbum = false;
|
||||
bool isArchive = false;
|
||||
bool isFavorite = false;
|
||||
SearchDisplayFilters({
|
||||
required this.isNotInAlbum,
|
||||
required this.isArchive,
|
||||
required this.isFavorite,
|
||||
});
|
||||
|
||||
SearchDisplayFilters copyWith({
|
||||
bool? isNotInAlbum,
|
||||
bool? isArchive,
|
||||
bool? isFavorite,
|
||||
}) {
|
||||
return SearchDisplayFilters(
|
||||
isNotInAlbum: isNotInAlbum ?? this.isNotInAlbum,
|
||||
isArchive: isArchive ?? this.isArchive,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'isNotInAlbum': isNotInAlbum,
|
||||
'isArchive': isArchive,
|
||||
'isFavorite': isFavorite,
|
||||
};
|
||||
}
|
||||
|
||||
factory SearchDisplayFilters.fromMap(Map<String, dynamic> map) {
|
||||
return SearchDisplayFilters(
|
||||
isNotInAlbum: map['isNotInAlbum'] as bool,
|
||||
isArchive: map['isArchive'] as bool,
|
||||
isFavorite: map['isFavorite'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchDisplayFilters.fromJson(String source) =>
|
||||
SearchDisplayFilters.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SearchDisplayFilters(isNotInAlbum: $isNotInAlbum, isArchive: $isArchive, isFavorite: $isFavorite)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchDisplayFilters other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.isNotInAlbum == isNotInAlbum &&
|
||||
other.isArchive == isArchive &&
|
||||
other.isFavorite == isFavorite;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
isNotInAlbum.hashCode ^ isArchive.hashCode ^ isFavorite.hashCode;
|
||||
}
|
||||
|
||||
class SearchFilter {
|
||||
String? context;
|
||||
String? filename;
|
||||
Set<PersonResponseDto> people;
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
SearchDateFilter date;
|
||||
SearchDisplayFilters display;
|
||||
|
||||
// Enum
|
||||
AssetType mediaType;
|
||||
|
||||
SearchFilter({
|
||||
this.context,
|
||||
this.filename,
|
||||
required this.people,
|
||||
required this.location,
|
||||
required this.camera,
|
||||
required this.date,
|
||||
required this.display,
|
||||
required this.mediaType,
|
||||
});
|
||||
|
||||
SearchFilter copyWith({
|
||||
String? context,
|
||||
String? filename,
|
||||
Set<PersonResponseDto>? people,
|
||||
SearchLocationFilter? location,
|
||||
SearchCameraFilter? camera,
|
||||
SearchDateFilter? date,
|
||||
SearchDisplayFilters? display,
|
||||
AssetType? mediaType,
|
||||
}) {
|
||||
return SearchFilter(
|
||||
context: context ?? this.context,
|
||||
filename: filename ?? this.filename,
|
||||
people: people ?? this.people,
|
||||
location: location ?? this.location,
|
||||
camera: camera ?? this.camera,
|
||||
date: date ?? this.date,
|
||||
display: display ?? this.display,
|
||||
mediaType: mediaType ?? this.mediaType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchFilter(context: $context, filename: $filename, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchFilter other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.context == context &&
|
||||
other.filename == filename &&
|
||||
other.people == people &&
|
||||
other.location == location &&
|
||||
other.camera == camera &&
|
||||
other.date == date &&
|
||||
other.display == display &&
|
||||
other.mediaType == mediaType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return context.hashCode ^
|
||||
filename.hashCode ^
|
||||
people.hashCode ^
|
||||
location.hashCode ^
|
||||
camera.hashCode ^
|
||||
date.hashCode ^
|
||||
display.hashCode ^
|
||||
mediaType.hashCode;
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_filter.dart';
|
||||
import 'package:immich_mobile/modules/search/services/search.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'paginated_search.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class PaginatedSearch extends _$PaginatedSearch {
|
||||
Future<List<Asset>?> _search(SearchFilter filter, int page) async {
|
||||
final service = ref.read(searchServiceProvider);
|
||||
final result = await service.search(filter, page);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Asset>> build() async {
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<Asset>> getNextPage(SearchFilter filter, int nextPage) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
final newState = await AsyncValue.guard(() async {
|
||||
final assets = await _search(filter, nextPage);
|
||||
|
||||
if (assets != null) {
|
||||
return [...?state.value, ...assets];
|
||||
}
|
||||
});
|
||||
|
||||
state = newState.valueOrNull == null
|
||||
? const AsyncValue.data([])
|
||||
: AsyncValue.data(newState.value!);
|
||||
|
||||
return newState.valueOrNull ?? [];
|
||||
}
|
||||
|
||||
clear() {
|
||||
state = const AsyncValue.data([]);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
AsyncValue<RenderList> paginatedSearchRenderList(
|
||||
PaginatedSearchRenderListRef ref,
|
||||
) {
|
||||
final assets = ref.watch(paginatedSearchProvider).value;
|
||||
|
||||
if (assets != null) {
|
||||
return ref.watch(
|
||||
renderListProviderWithGrouping(
|
||||
(assets, GroupAssetsBy.none),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const AsyncValue.loading();
|
||||
}
|
||||
}
|
44
mobile/lib/modules/search/providers/paginated_search.provider.g.dart
generated
Normal file
44
mobile/lib/modules/search/providers/paginated_search.provider.g.dart
generated
Normal file
@ -0,0 +1,44 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'paginated_search.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$paginatedSearchRenderListHash() =>
|
||||
r'c2cc2381ee6ea8f8e08d6d4c1289bbf0c6b9647e';
|
||||
|
||||
/// See also [paginatedSearchRenderList].
|
||||
@ProviderFor(paginatedSearchRenderList)
|
||||
final paginatedSearchRenderListProvider =
|
||||
AutoDisposeProvider<AsyncValue<RenderList>>.internal(
|
||||
paginatedSearchRenderList,
|
||||
name: r'paginatedSearchRenderListProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$paginatedSearchRenderListHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef PaginatedSearchRenderListRef
|
||||
= AutoDisposeProviderRef<AsyncValue<RenderList>>;
|
||||
String _$paginatedSearchHash() => r'8312f358261368cf2b5572b839fdd8f8fbe9a62e';
|
||||
|
||||
/// See also [PaginatedSearch].
|
||||
@ProviderFor(PaginatedSearch)
|
||||
final paginatedSearchProvider =
|
||||
AutoDisposeAsyncNotifierProvider<PaginatedSearch, List<Asset>>.internal(
|
||||
PaginatedSearch.new,
|
||||
name: r'paginatedSearchProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$paginatedSearchHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$PaginatedSearch = AutoDisposeAsyncNotifier<List<Asset>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
@ -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<List<CuratedContent>> 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<RenderList> 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<bool> 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<List<PersonResponseDto>> getAllPeople(
|
||||
GetAllPeopleRef ref,
|
||||
) async {
|
||||
final PersonService personService = ref.read(personServiceProvider);
|
||||
|
||||
final people = await personService.getAllPeople();
|
||||
|
||||
return people;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<RenderList> 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<bool> 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;
|
||||
}
|
||||
|
@ -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<List<CuratedContent>>.internal(
|
||||
getCuratedPeople,
|
||||
name: r'getCuratedPeopleProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$getCuratedPeopleHash,
|
||||
/// See also [getAllPeople].
|
||||
@ProviderFor(getAllPeople)
|
||||
final getAllPeopleProvider =
|
||||
AutoDisposeFutureProvider<List<PersonResponseDto>>.internal(
|
||||
getAllPeople,
|
||||
name: r'getAllPeopleProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$getAllPeopleHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef GetCuratedPeopleRef
|
||||
= AutoDisposeFutureProviderRef<List<CuratedContent>>;
|
||||
typedef GetAllPeopleRef = AutoDisposeFutureProviderRef<List<PersonResponseDto>>;
|
||||
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)
|
||||
|
@ -0,0 +1,27 @@
|
||||
import 'package:immich_mobile/modules/search/services/search.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'search_filter.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<String>> getSearchSuggestions(
|
||||
GetSearchSuggestionsRef ref,
|
||||
SearchSuggestionType type, {
|
||||
String? locationCountry,
|
||||
String? locationState,
|
||||
String? make,
|
||||
String? model,
|
||||
}) async {
|
||||
final SearchService service = ref.read(searchServiceProvider);
|
||||
|
||||
final suggestions = await service.getSearchSuggestions(
|
||||
type,
|
||||
country: locationCountry,
|
||||
state: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
|
||||
return suggestions ?? [];
|
||||
}
|
229
mobile/lib/modules/search/providers/search_filter.provider.g.dart
generated
Normal file
229
mobile/lib/modules/search/providers/search_filter.provider.g.dart
generated
Normal file
@ -0,0 +1,229 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_filter.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$getSearchSuggestionsHash() =>
|
||||
r'bc1e9a1a060868f14e6eb970d2251dbfe39c6866';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
/// See also [getSearchSuggestions].
|
||||
@ProviderFor(getSearchSuggestions)
|
||||
const getSearchSuggestionsProvider = GetSearchSuggestionsFamily();
|
||||
|
||||
/// See also [getSearchSuggestions].
|
||||
class GetSearchSuggestionsFamily extends Family<AsyncValue<List<String>>> {
|
||||
/// See also [getSearchSuggestions].
|
||||
const GetSearchSuggestionsFamily();
|
||||
|
||||
/// See also [getSearchSuggestions].
|
||||
GetSearchSuggestionsProvider call(
|
||||
SearchSuggestionType type, {
|
||||
String? locationCountry,
|
||||
String? locationState,
|
||||
String? make,
|
||||
String? model,
|
||||
}) {
|
||||
return GetSearchSuggestionsProvider(
|
||||
type,
|
||||
locationCountry: locationCountry,
|
||||
locationState: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
GetSearchSuggestionsProvider getProviderOverride(
|
||||
covariant GetSearchSuggestionsProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.type,
|
||||
locationCountry: provider.locationCountry,
|
||||
locationState: provider.locationState,
|
||||
make: provider.make,
|
||||
model: provider.model,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'getSearchSuggestionsProvider';
|
||||
}
|
||||
|
||||
/// See also [getSearchSuggestions].
|
||||
class GetSearchSuggestionsProvider
|
||||
extends AutoDisposeFutureProvider<List<String>> {
|
||||
/// See also [getSearchSuggestions].
|
||||
GetSearchSuggestionsProvider(
|
||||
SearchSuggestionType type, {
|
||||
String? locationCountry,
|
||||
String? locationState,
|
||||
String? make,
|
||||
String? model,
|
||||
}) : this._internal(
|
||||
(ref) => getSearchSuggestions(
|
||||
ref as GetSearchSuggestionsRef,
|
||||
type,
|
||||
locationCountry: locationCountry,
|
||||
locationState: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
),
|
||||
from: getSearchSuggestionsProvider,
|
||||
name: r'getSearchSuggestionsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$getSearchSuggestionsHash,
|
||||
dependencies: GetSearchSuggestionsFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
GetSearchSuggestionsFamily._allTransitiveDependencies,
|
||||
type: type,
|
||||
locationCountry: locationCountry,
|
||||
locationState: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
|
||||
GetSearchSuggestionsProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.type,
|
||||
required this.locationCountry,
|
||||
required this.locationState,
|
||||
required this.make,
|
||||
required this.model,
|
||||
}) : super.internal();
|
||||
|
||||
final SearchSuggestionType type;
|
||||
final String? locationCountry;
|
||||
final String? locationState;
|
||||
final String? make;
|
||||
final String? model;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<List<String>> Function(GetSearchSuggestionsRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: GetSearchSuggestionsProvider._internal(
|
||||
(ref) => create(ref as GetSearchSuggestionsRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
type: type,
|
||||
locationCountry: locationCountry,
|
||||
locationState: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<List<String>> createElement() {
|
||||
return _GetSearchSuggestionsProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is GetSearchSuggestionsProvider &&
|
||||
other.type == type &&
|
||||
other.locationCountry == locationCountry &&
|
||||
other.locationState == locationState &&
|
||||
other.make == make &&
|
||||
other.model == model;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, type.hashCode);
|
||||
hash = _SystemHash.combine(hash, locationCountry.hashCode);
|
||||
hash = _SystemHash.combine(hash, locationState.hashCode);
|
||||
hash = _SystemHash.combine(hash, make.hashCode);
|
||||
hash = _SystemHash.combine(hash, model.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin GetSearchSuggestionsRef on AutoDisposeFutureProviderRef<List<String>> {
|
||||
/// The parameter `type` of this provider.
|
||||
SearchSuggestionType get type;
|
||||
|
||||
/// The parameter `locationCountry` of this provider.
|
||||
String? get locationCountry;
|
||||
|
||||
/// The parameter `locationState` of this provider.
|
||||
String? get locationState;
|
||||
|
||||
/// The parameter `make` of this provider.
|
||||
String? get make;
|
||||
|
||||
/// The parameter `model` of this provider.
|
||||
String? get model;
|
||||
}
|
||||
|
||||
class _GetSearchSuggestionsProviderElement
|
||||
extends AutoDisposeFutureProviderElement<List<String>>
|
||||
with GetSearchSuggestionsRef {
|
||||
_GetSearchSuggestionsProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
SearchSuggestionType get type =>
|
||||
(origin as GetSearchSuggestionsProvider).type;
|
||||
@override
|
||||
String? get locationCountry =>
|
||||
(origin as GetSearchSuggestionsProvider).locationCountry;
|
||||
@override
|
||||
String? get locationState =>
|
||||
(origin as GetSearchSuggestionsProvider).locationState;
|
||||
@override
|
||||
String? get make => (origin as GetSearchSuggestionsProvider).make;
|
||||
@override
|
||||
String? get model => (origin as GetSearchSuggestionsProvider).model;
|
||||
}
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
@ -1,67 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_result_page_state.model.dart';
|
||||
|
||||
import 'package:immich_mobile/modules/search/services/search.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
|
||||
SearchResultPageNotifier(this._searchService)
|
||||
: super(
|
||||
SearchResultPageState(
|
||||
searchResult: [],
|
||||
isError: false,
|
||||
isLoading: true,
|
||||
isSuccess: false,
|
||||
isSmart: false,
|
||||
),
|
||||
);
|
||||
|
||||
final SearchService _searchService;
|
||||
|
||||
Future<void> search(String searchTerm, {bool smartSearch = true}) async {
|
||||
state = state.copyWith(
|
||||
searchResult: [],
|
||||
isError: false,
|
||||
isLoading: true,
|
||||
isSuccess: false,
|
||||
);
|
||||
|
||||
List<Asset>? assets =
|
||||
await _searchService.searchAsset(searchTerm, smartSearch: smartSearch);
|
||||
|
||||
if (assets != null) {
|
||||
state = state.copyWith(
|
||||
searchResult: assets,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isSmart: smartSearch,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
searchResult: [],
|
||||
isError: true,
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
isSmart: smartSearch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final searchResultPageProvider =
|
||||
StateNotifierProvider<SearchResultPageNotifier, SearchResultPageState>(
|
||||
(ref) {
|
||||
return SearchResultPageNotifier(ref.watch(searchServiceProvider));
|
||||
});
|
||||
|
||||
final searchRenderListProvider = Provider((ref) {
|
||||
final result = ref.watch(searchResultPageProvider);
|
||||
return ref.watch(
|
||||
renderListProviderWithGrouping(
|
||||
(result.searchResult, result.isSmart ? GroupAssetsBy.none : null),
|
||||
),
|
||||
);
|
||||
});
|
@ -20,7 +20,7 @@ class PersonService {
|
||||
|
||||
PersonService(this._apiService, this._db);
|
||||
|
||||
Future<List<PersonResponseDto>> getCuratedPeople() async {
|
||||
Future<List<PersonResponseDto>> getAllPeople() async {
|
||||
try {
|
||||
final peopleResponseDto = await _apiService.personApi.getAllPeople();
|
||||
return peopleResponseDto?.people ?? [];
|
||||
|
@ -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<List<Asset>?> searchAsset(
|
||||
String searchTerm, {
|
||||
bool smartSearch = true,
|
||||
Future<List<String>?> 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<List<Asset>?> search(SearchFilter filter, int page) async {
|
||||
try {
|
||||
SearchResponseDto? response;
|
||||
AssetTypeEnum? type;
|
||||
if (filter.mediaType == AssetType.image) {
|
||||
type = AssetTypeEnum.IMAGE;
|
||||
} else if (filter.mediaType == AssetType.video) {
|
||||
type = AssetTypeEnum.VIDEO;
|
||||
}
|
||||
|
||||
if (filter.context != null && filter.context!.isNotEmpty) {
|
||||
response = await _apiService.searchApi.searchSmart(
|
||||
SmartSearchDto(
|
||||
query: filter.context!,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
isArchived: filter.display.isArchive,
|
||||
isFavorite: filter.display.isFavorite,
|
||||
isNotInAlbum: filter.display.isNotInAlbum,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
response = await _apiService.searchApi.searchMetadata(
|
||||
MetadataSearchDto(
|
||||
originalFileName:
|
||||
filter.filename != null && filter.filename!.isNotEmpty
|
||||
? filter.filename
|
||||
: null,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
isArchived: filter.display.isArchive,
|
||||
isFavorite: filter.display.isFavorite,
|
||||
isNotInAlbum: filter.display.isNotInAlbum,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
// 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<List<CuratedLocationsResponseDto>?> getCuratedLocation() async {
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -1,99 +0,0 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
|
||||
class ImmichSearchBar extends HookConsumerWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ImmichSearchBar({
|
||||
super.key,
|
||||
required this.searchFocusNode,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final FocusNode searchFocusNode;
|
||||
final Function(String) onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchTermController = useTextEditingController(text: "");
|
||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||
|
||||
focusSearch() {
|
||||
searchTermController.clear();
|
||||
ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
|
||||
ref.watch(searchPageStateProvider.notifier).enableSearch();
|
||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
|
||||
|
||||
searchFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
searchFocusNotifier.addListener(focusSearch);
|
||||
return () {
|
||||
searchFocusNotifier.removeListener(focusSearch);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: isSearchEnabled
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
searchFocusNode.unfocus();
|
||||
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
||||
searchTermController.clear();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.search_rounded,
|
||||
size: 20,
|
||||
),
|
||||
title: TextField(
|
||||
controller: searchTermController,
|
||||
focusNode: searchFocusNode,
|
||||
autofocus: false,
|
||||
onTap: focusSearch,
|
||||
onSubmitted: (searchTerm) {
|
||||
onSubmitted(searchTerm);
|
||||
searchTermController.clear();
|
||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
|
||||
},
|
||||
onChanged: (value) {
|
||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'search_bar_hint'.tr(),
|
||||
hintStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.themeData.colorScheme.onSurface.withOpacity(0.75),
|
||||
),
|
||||
enabledBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
focusedBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
// Used to focus search from outside this widget.
|
||||
// For example when double pressing the search nav icon.
|
||||
final searchFocusNotifier = SearchFocusNotifier();
|
||||
|
||||
class SearchFocusNotifier with ChangeNotifier {
|
||||
void requestFocus() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
120
mobile/lib/modules/search/ui/search_filter/camera_picker.dart
Normal file
120
mobile/lib/modules/search/ui/search_filter/camera_picker.dart
Normal file
@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_filter.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_filter.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class CameraPicker extends HookConsumerWidget {
|
||||
const CameraPicker({super.key, required this.onSelect, this.filter});
|
||||
|
||||
final Function(Map<String, String?>) onSelect;
|
||||
final SearchCameraFilter? filter;
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final makeTextController = useTextEditingController(text: filter?.make);
|
||||
final modelTextController = useTextEditingController(text: filter?.model);
|
||||
final selectedMake = useState<String?>(filter?.make);
|
||||
final selectedModel = useState<String?>(filter?.model);
|
||||
|
||||
final make = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.cameraMake,
|
||||
),
|
||||
);
|
||||
|
||||
final models = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.cameraModel,
|
||||
make: selectedMake.value,
|
||||
),
|
||||
);
|
||||
|
||||
final inputDecorationTheme = InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(left: 16),
|
||||
);
|
||||
|
||||
final menuStyle = MenuStyle(
|
||||
shape: MaterialStatePropertyAll<OutlinedBorder>(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
// bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (make) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
width: context.width * 0.45,
|
||||
menuHeight: 400,
|
||||
label: const Text('Make'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
controller: makeTextController,
|
||||
menuStyle: menuStyle,
|
||||
leadingIcon: const Icon(Icons.photo_camera_rounded),
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedMake.value = value.toString();
|
||||
onSelect({
|
||||
'make': selectedMake.value,
|
||||
'model': selectedModel.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (models) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
width: context.width * 0.45,
|
||||
menuHeight: 400,
|
||||
label: const Text('Model'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
controller: modelTextController,
|
||||
menuStyle: menuStyle,
|
||||
leadingIcon: const Icon(Icons.camera),
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedModel.value = value.toString();
|
||||
onSelect({
|
||||
'make': selectedMake.value,
|
||||
'model': selectedModel.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_filter.dart';
|
||||
|
||||
enum DisplayOption {
|
||||
notInAlbum,
|
||||
favorite,
|
||||
archive,
|
||||
}
|
||||
|
||||
class DisplayOptionPicker extends HookWidget {
|
||||
const DisplayOptionPicker({
|
||||
super.key,
|
||||
required this.onSelect,
|
||||
this.filter,
|
||||
});
|
||||
|
||||
final Function(Map<DisplayOption, bool>) onSelect;
|
||||
final SearchDisplayFilters? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final options = useState<Map<DisplayOption, bool>>({
|
||||
DisplayOption.notInAlbum: filter?.isNotInAlbum ?? false,
|
||||
DisplayOption.favorite: filter?.isFavorite ?? false,
|
||||
DisplayOption.archive: filter?.isArchive ?? false,
|
||||
});
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
title: const Text('Not in album'),
|
||||
value: options.value[DisplayOption.notInAlbum],
|
||||
onChanged: (bool? value) {
|
||||
options.value = {
|
||||
...options.value,
|
||||
DisplayOption.notInAlbum: value!,
|
||||
};
|
||||
onSelect(options.value);
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Favorite'),
|
||||
value: options.value[DisplayOption.favorite],
|
||||
onChanged: (value) {
|
||||
options.value = {
|
||||
...options.value,
|
||||
DisplayOption.favorite: value!,
|
||||
};
|
||||
onSelect(options.value);
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Archive'),
|
||||
value: options.value[DisplayOption.archive],
|
||||
onChanged: (value) {
|
||||
options.value = {
|
||||
...options.value,
|
||||
DisplayOption.archive: value!,
|
||||
};
|
||||
onSelect(options.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class FilterBottomSheetScaffold extends StatelessWidget {
|
||||
const FilterBottomSheetScaffold({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onSearch,
|
||||
required this.onClear,
|
||||
required this.title,
|
||||
this.expanded,
|
||||
});
|
||||
|
||||
final bool? expanded;
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Function() onSearch;
|
||||
final Function() onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
buildChildWidget() {
|
||||
if (expanded != null && expanded == true) {
|
||||
return Expanded(child: child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: context.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
buildChildWidget(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
onClear();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
onSearch();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Apply filter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
166
mobile/lib/modules/search/ui/search_filter/location_picker.dart
Normal file
166
mobile/lib/modules/search/ui/search_filter/location_picker.dart
Normal file
@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_filter.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_filter.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class LocationPicker extends HookConsumerWidget {
|
||||
const LocationPicker({super.key, required this.onSelected, this.filter});
|
||||
|
||||
final Function(Map<String, String?>) onSelected;
|
||||
final SearchLocationFilter? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final countryTextController =
|
||||
useTextEditingController(text: filter?.country);
|
||||
final stateTextController = useTextEditingController(text: filter?.state);
|
||||
final cityTextController = useTextEditingController(text: filter?.city);
|
||||
|
||||
final selectedCountry = useState<String?>(filter?.country);
|
||||
final selectedState = useState<String?>(filter?.state);
|
||||
final selectedCity = useState<String?>(filter?.city);
|
||||
|
||||
final countries = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.country,
|
||||
locationCountry: selectedCountry.value,
|
||||
locationState: selectedState.value,
|
||||
),
|
||||
);
|
||||
|
||||
final states = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.state,
|
||||
locationCountry: selectedCountry.value,
|
||||
locationState: selectedState.value,
|
||||
),
|
||||
);
|
||||
|
||||
final cities = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.city,
|
||||
locationCountry: selectedCountry.value,
|
||||
locationState: selectedState.value,
|
||||
),
|
||||
);
|
||||
|
||||
final inputDecorationTheme = InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(left: 16),
|
||||
);
|
||||
|
||||
final menuStyle = MenuStyle(
|
||||
shape: MaterialStatePropertyAll<OutlinedBorder>(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (countries) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
menuHeight: 400,
|
||||
width: context.width * 0.9,
|
||||
label: const Text('Country'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
menuStyle: menuStyle,
|
||||
controller: countryTextController,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedCountry.value = value.toString();
|
||||
onSelected({
|
||||
'country': selectedCountry.value,
|
||||
'state': selectedState.value,
|
||||
'city': selectedCity.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (states) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
menuHeight: 400,
|
||||
width: context.width * 0.9,
|
||||
label: const Text('State'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
menuStyle: menuStyle,
|
||||
controller: stateTextController,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedState.value = value.toString();
|
||||
onSelected({
|
||||
'country': selectedCountry.value,
|
||||
'state': selectedState.value,
|
||||
'city': selectedCity.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (cities) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
menuHeight: 400,
|
||||
width: context.width * 0.9,
|
||||
label: const Text('City'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
menuStyle: menuStyle,
|
||||
controller: cityTextController,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedCity.value = value.toString();
|
||||
onSelected({
|
||||
'country': selectedCountry.value,
|
||||
'state': selectedState.value,
|
||||
'city': selectedCity.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class MediaTypePicker extends HookWidget {
|
||||
const MediaTypePicker({super.key, required this.onSelect, this.filter});
|
||||
|
||||
final Function(AssetType) onSelect;
|
||||
final AssetType? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedMediaType = useState(filter ?? AssetType.other);
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
RadioListTile(
|
||||
title: const Text("All"),
|
||||
value: AssetType.other,
|
||||
onChanged: (value) {
|
||||
selectedMediaType.value = value!;
|
||||
onSelect(value);
|
||||
},
|
||||
groupValue: selectedMediaType.value,
|
||||
),
|
||||
RadioListTile(
|
||||
title: const Text("Image"),
|
||||
value: AssetType.image,
|
||||
onChanged: (value) {
|
||||
selectedMediaType.value = value!;
|
||||
onSelect(value);
|
||||
},
|
||||
groupValue: selectedMediaType.value,
|
||||
),
|
||||
RadioListTile(
|
||||
title: const Text("Video"),
|
||||
value: AssetType.video,
|
||||
onChanged: (value) {
|
||||
selectedMediaType.value = value!;
|
||||
onSelect(value);
|
||||
},
|
||||
groupValue: selectedMediaType.value,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart' as local_store;
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class PeoplePicker extends HookConsumerWidget {
|
||||
const PeoplePicker({super.key, required this.onSelect, this.filter});
|
||||
|
||||
final Function(Set<PersonResponseDto>) onSelect;
|
||||
final Set<PersonResponseDto>? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var imageSize = 45.0;
|
||||
final people = ref.watch(getAllPeopleProvider);
|
||||
final headers = {
|
||||
"x-immich-user-token":
|
||||
local_store.Store.get(local_store.StoreKey.accessToken),
|
||||
};
|
||||
final selectedPeople = useState<Set<PersonResponseDto>>(filter ?? {});
|
||||
|
||||
return people.widgetWhen(
|
||||
onData: (people) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: people.length,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) {
|
||||
final person = people[index];
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
person.name,
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
leading: SizedBox(
|
||||
height: imageSize,
|
||||
child: Material(
|
||||
shape: const CircleBorder(side: BorderSide.none),
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: imageSize / 2,
|
||||
backgroundImage: NetworkImage(
|
||||
getFaceThumbnailUrl(person.id),
|
||||
headers: headers,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (selectedPeople.value.contains(person)) {
|
||||
selectedPeople.value.remove(person);
|
||||
} else {
|
||||
selectedPeople.value.add(person);
|
||||
}
|
||||
|
||||
selectedPeople.value = {...selectedPeople.value};
|
||||
onSelect(selectedPeople.value);
|
||||
},
|
||||
selected: selectedPeople.value.contains(person),
|
||||
selectedTileColor: context.primaryColor.withOpacity(0.2),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class SearchFilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final Function() onTap;
|
||||
final Widget? currentFilter;
|
||||
final IconData icon;
|
||||
|
||||
const SearchFilterChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
required this.icon,
|
||||
this.currentFilter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (currentFilter != null) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: context.primaryColor.withAlpha(25),
|
||||
shape: StadiumBorder(
|
||||
side: BorderSide(color: context.primaryColor),
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
currentFilter!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape:
|
||||
StadiumBorder(side: BorderSide(color: Colors.grey.withAlpha(100))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<T> showFilterBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
bool isScrollControlled = false,
|
||||
bool isDismissible = true,
|
||||
}) async {
|
||||
return await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
useSafeArea: false,
|
||||
isDismissible: isDismissible,
|
||||
showDragHandle: isDismissible,
|
||||
builder: (BuildContext context) {
|
||||
return child;
|
||||
},
|
||||
);
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
|
||||
class SearchSuggestionList extends ConsumerWidget {
|
||||
const SearchSuggestionList({super.key, required this.onSubmitted});
|
||||
|
||||
final Function(String) onSubmitted;
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
|
||||
final searchSuggestion =
|
||||
ref.watch(searchPageStateProvider).searchSuggestion;
|
||||
|
||||
return Container(
|
||||
color: searchTerm.isEmpty
|
||||
? Colors.black.withOpacity(0.5)
|
||||
: context.scaffoldBackgroundColor,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: context.isDarkTheme ? Colors.grey[800] : Colors.grey[100],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'search_suggestion_list_smart_search_hint_1'.tr(),
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: 'search_suggestion_list_smart_search_hint_2'.tr(),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: true,
|
||||
child: ListView.builder(
|
||||
itemBuilder: ((context, index) {
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
onSubmitted("m:${searchSuggestion[index]}");
|
||||
},
|
||||
title: Text(searchSuggestion[index]),
|
||||
);
|
||||
}),
|
||||
itemCount: searchSuggestion.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,35 +1,38 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package: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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
563
mobile/lib/modules/search/views/search_input_page.dart
Normal file
563
mobile/lib/modules/search/views/search_input_page.dart
Normal file
@ -0,0 +1,563 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_filter.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_filter/camera_picker.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_filter/display_option_picker.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_filter/filter_bottom_sheet_scaffold.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_filter/location_picker.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_filter/media_type_picker.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_filter/people_picker.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_filter/search_filter_chip.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_filter/search_filter_utils.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@RoutePage()
|
||||
class SearchInputPage extends HookConsumerWidget {
|
||||
const SearchInputPage({super.key, this.prefilter});
|
||||
|
||||
final SearchFilter? prefilter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isContextualSearch = useState(true);
|
||||
final textSearchController = useTextEditingController();
|
||||
final filter = useState<SearchFilter>(
|
||||
SearchFilter(
|
||||
people: prefilter?.people ?? {},
|
||||
location: prefilter?.location ?? SearchLocationFilter(),
|
||||
camera: prefilter?.camera ?? SearchCameraFilter(),
|
||||
date: prefilter?.date ?? SearchDateFilter(),
|
||||
display: prefilter?.display ??
|
||||
SearchDisplayFilters(
|
||||
isNotInAlbum: false,
|
||||
isArchive: false,
|
||||
isFavorite: false,
|
||||
),
|
||||
mediaType: prefilter?.mediaType ?? AssetType.other,
|
||||
),
|
||||
);
|
||||
|
||||
final previousFilter = useState(filter.value);
|
||||
|
||||
final peopleCurrentFilterWidget = useState<Widget?>(null);
|
||||
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
|
||||
final currentPage = useState(1);
|
||||
final searchProvider = ref.watch(paginatedSearchProvider);
|
||||
final searchResultCount = useState(0);
|
||||
|
||||
search() async {
|
||||
if (prefilter == null && filter.value == previousFilter.value) return;
|
||||
|
||||
ref.watch(paginatedSearchProvider.notifier).clear();
|
||||
|
||||
currentPage.value = 1;
|
||||
|
||||
final searchResult = await ref
|
||||
.watch(paginatedSearchProvider.notifier)
|
||||
.getNextPage(filter.value, currentPage.value);
|
||||
previousFilter.value = filter.value;
|
||||
|
||||
searchResultCount.value = searchResult.length;
|
||||
}
|
||||
|
||||
searchPrefilter() {
|
||||
if (prefilter != null) {
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() {
|
||||
search();
|
||||
|
||||
if (prefilter!.location.city != null) {
|
||||
locationCurrentFilterWidget.value = Text(
|
||||
prefilter!.location.city!,
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
searchPrefilter();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
loadMoreSearchResult() async {
|
||||
currentPage.value += 1;
|
||||
final searchResult = await ref
|
||||
.watch(paginatedSearchProvider.notifier)
|
||||
.getNextPage(filter.value, currentPage.value);
|
||||
searchResultCount.value = searchResult.length;
|
||||
}
|
||||
|
||||
showPeoplePicker() {
|
||||
handleOnSelect(Set<PersonResponseDto> value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
people: value,
|
||||
);
|
||||
|
||||
peopleCurrentFilterWidget.value = Text(
|
||||
value.map((e) => e.name != '' ? e.name : "No name").join(', '),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
people: {},
|
||||
);
|
||||
|
||||
peopleCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
child: FractionallySizedBox(
|
||||
heightFactor: 0.8,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'Select people',
|
||||
expanded: true,
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: PeoplePicker(
|
||||
onSelect: handleOnSelect,
|
||||
filter: filter.value.people,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
showLocationPicker() {
|
||||
handleOnSelect(Map<String, String?> value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
location: SearchLocationFilter(
|
||||
country: value['country'],
|
||||
city: value['city'],
|
||||
state: value['state'],
|
||||
),
|
||||
);
|
||||
|
||||
final locationText = <String>[];
|
||||
if (value['country'] != null) {
|
||||
locationText.add(value['country']!);
|
||||
}
|
||||
|
||||
if (value['state'] != null) {
|
||||
locationText.add(value['state']!);
|
||||
}
|
||||
|
||||
if (value['city'] != null) {
|
||||
locationText.add(value['city']!);
|
||||
}
|
||||
|
||||
locationCurrentFilterWidget.value = Text(
|
||||
locationText.join(', '),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
location: SearchLocationFilter(),
|
||||
);
|
||||
|
||||
locationCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'Select location',
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: LocationPicker(
|
||||
onSelected: handleOnSelect,
|
||||
filter: filter.value.location,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
showCameraPicker() {
|
||||
handleOnSelect(Map<String, String?> value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
camera: SearchCameraFilter(
|
||||
make: value['make'],
|
||||
model: value['model'],
|
||||
),
|
||||
);
|
||||
|
||||
cameraCurrentFilterWidget.value = Text(
|
||||
'${value['make'] ?? ''} ${value['model'] ?? ''}',
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
camera: SearchCameraFilter(),
|
||||
);
|
||||
|
||||
cameraCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'Select camera type',
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: CameraPicker(
|
||||
onSelect: handleOnSelect,
|
||||
filter: filter.value.camera,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
showDatePicker() async {
|
||||
final firstDate = DateTime(1900);
|
||||
final lastDate = DateTime.now();
|
||||
|
||||
final date = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
currentDate: DateTime.now(),
|
||||
initialDateRange: DateTimeRange(
|
||||
start: filter.value.date.takenAfter ?? lastDate,
|
||||
end: filter.value.date.takenBefore ?? lastDate,
|
||||
),
|
||||
helpText: 'Select a date range',
|
||||
cancelText: 'Cancel',
|
||||
confirmText: 'Select',
|
||||
saveText: 'Save',
|
||||
errorFormatText: 'Invalid date format',
|
||||
errorInvalidText: 'Invalid date',
|
||||
fieldStartHintText: 'Start date',
|
||||
fieldEndHintText: 'End date',
|
||||
initialEntryMode: DatePickerEntryMode.input,
|
||||
);
|
||||
|
||||
if (date == null) {
|
||||
filter.value = filter.value.copyWith(
|
||||
date: SearchDateFilter(),
|
||||
);
|
||||
|
||||
dateRangeCurrentFilterWidget.value = null;
|
||||
search();
|
||||
return;
|
||||
}
|
||||
|
||||
filter.value = filter.value.copyWith(
|
||||
date: SearchDateFilter(
|
||||
takenAfter: date.start,
|
||||
takenBefore: date.end.add(
|
||||
const Duration(
|
||||
hours: 23,
|
||||
minutes: 59,
|
||||
seconds: 59,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// If date range is less than 24 hours, set the end date to the end of the day
|
||||
if (date.end.difference(date.start).inHours < 24) {
|
||||
dateRangeCurrentFilterWidget.value = Text(
|
||||
date.start.toLocal().toIso8601String().split('T').first,
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
} else {
|
||||
dateRangeCurrentFilterWidget.value = Text(
|
||||
'${date.start.toLocal().toIso8601String().split('T').first} to ${date.end.toLocal().toIso8601String().split('T').first}',
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
search();
|
||||
}
|
||||
|
||||
// MEDIA PICKER
|
||||
showMediaTypePicker() {
|
||||
handleOnSelected(AssetType assetType) {
|
||||
filter.value = filter.value.copyWith(
|
||||
mediaType: assetType,
|
||||
);
|
||||
|
||||
mediaTypeCurrentFilterWidget.value = Text(
|
||||
assetType == AssetType.image ? 'Image' : 'Video',
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
mediaType: AssetType.other,
|
||||
);
|
||||
|
||||
mediaTypeCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'Select media type',
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: MediaTypePicker(
|
||||
onSelect: handleOnSelected,
|
||||
filter: filter.value.mediaType,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// DISPLAY OPTION
|
||||
showDisplayOptionPicker() {
|
||||
handleOnSelect(Map<DisplayOption, bool> value) {
|
||||
final filterText = <String>[];
|
||||
|
||||
value.forEach((key, value) {
|
||||
switch (key) {
|
||||
case DisplayOption.notInAlbum:
|
||||
filter.value = filter.value.copyWith(
|
||||
display: filter.value.display.copyWith(
|
||||
isNotInAlbum: value,
|
||||
),
|
||||
);
|
||||
if (value) filterText.add('Not in album');
|
||||
break;
|
||||
case DisplayOption.archive:
|
||||
filter.value = filter.value.copyWith(
|
||||
display: filter.value.display.copyWith(
|
||||
isArchive: value,
|
||||
),
|
||||
);
|
||||
if (value) filterText.add('Archive');
|
||||
break;
|
||||
case DisplayOption.favorite:
|
||||
filter.value = filter.value.copyWith(
|
||||
display: filter.value.display.copyWith(
|
||||
isFavorite: value,
|
||||
),
|
||||
);
|
||||
if (value) filterText.add('Favorite');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
displayOptionCurrentFilterWidget.value = Text(
|
||||
filterText.join(', '),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
display: SearchDisplayFilters(
|
||||
isNotInAlbum: false,
|
||||
isArchive: false,
|
||||
isFavorite: false,
|
||||
),
|
||||
);
|
||||
|
||||
displayOptionCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'Display options',
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: DisplayOptionPicker(
|
||||
onSelect: handleOnSelect,
|
||||
filter: filter.value.display,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
handleTextSubmitted(String value) {
|
||||
if (isContextualSearch.value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
context: value,
|
||||
filename: null,
|
||||
);
|
||||
} else {
|
||||
filter.value = filter.value.copyWith(filename: value, context: null);
|
||||
}
|
||||
|
||||
search();
|
||||
}
|
||||
|
||||
buildSearchResult() {
|
||||
return switch (searchProvider) {
|
||||
AsyncData() => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: NotificationListener<ScrollEndNotification>(
|
||||
onNotification: (notification) {
|
||||
final metrics = notification.metrics;
|
||||
final shouldLoadMore = searchResultCount.value > 75;
|
||||
if (metrics.pixels >= metrics.maxScrollExtent &&
|
||||
shouldLoadMore) {
|
||||
loadMoreSearchResult();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
child: MultiselectGrid(
|
||||
renderListProvider: paginatedSearchRenderListProvider,
|
||||
archiveEnabled: true,
|
||||
deleteEnabled: true,
|
||||
editEnabled: true,
|
||||
favoriteEnabled: true,
|
||||
stackEnabled: false,
|
||||
emptyIndicator: const SizedBox(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AsyncError(:final error) => Text('Error: $error'),
|
||||
_ => const Expanded(child: Center(child: CircularProgressIndicator())),
|
||||
};
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: isContextualSearch.value
|
||||
? const Icon(Icons.abc_rounded)
|
||||
: const Icon(Icons.image_search_rounded),
|
||||
onPressed: () {
|
||||
isContextualSearch.value = !isContextualSearch.value;
|
||||
textSearchController.clear();
|
||||
},
|
||||
),
|
||||
],
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: () {
|
||||
context.router.pop();
|
||||
},
|
||||
),
|
||||
title: TextField(
|
||||
controller: textSearchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: isContextualSearch.value
|
||||
? 'Sunrise on the beach'
|
||||
: 'File name or extension',
|
||||
hintStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.themeData.colorScheme.onSurface.withOpacity(0.75),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
enabledBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
focusedBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
onSubmitted: handleTextSubmitted,
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
SearchFilterChip(
|
||||
icon: Icons.people_alt_rounded,
|
||||
onTap: showPeoplePicker,
|
||||
label: 'People',
|
||||
currentFilter: peopleCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.location_pin,
|
||||
onTap: showLocationPicker,
|
||||
label: 'Location',
|
||||
currentFilter: locationCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.camera_alt_rounded,
|
||||
onTap: showCameraPicker,
|
||||
label: 'Camera',
|
||||
currentFilter: cameraCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.date_range_rounded,
|
||||
onTap: showDatePicker,
|
||||
label: 'Date',
|
||||
currentFilter: dateRangeCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.video_collection_outlined,
|
||||
onTap: showMediaTypePicker,
|
||||
label: 'Media Type',
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.display_settings_outlined,
|
||||
onTap: showDisplayOptionPicker,
|
||||
label: 'Display Options',
|
||||
currentFilter: displayOptionCurrentFilterWidget.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
buildSearchResult(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,279 +1,274 @@
|
||||
import 'dart:math' as math;
|
||||
import '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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,213 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class SearchType {
|
||||
SearchType({required this.isSmart, required this.searchTerm});
|
||||
|
||||
final bool isSmart;
|
||||
final String searchTerm;
|
||||
}
|
||||
|
||||
SearchType _getSearchType(String searchTerm) {
|
||||
if (searchTerm.startsWith('m:')) {
|
||||
return SearchType(isSmart: false, searchTerm: searchTerm.substring(2));
|
||||
} else {
|
||||
return SearchType(isSmart: true, searchTerm: searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class SearchResultPage extends HookConsumerWidget {
|
||||
const SearchResultPage({
|
||||
super.key,
|
||||
required this.searchTerm,
|
||||
});
|
||||
|
||||
final String searchTerm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchTermController = useTextEditingController(text: "");
|
||||
final isNewSearch = useState(false);
|
||||
final currentSearchTerm = useState(searchTerm);
|
||||
|
||||
FocusNode? searchFocusNode;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
searchFocusNode = FocusNode();
|
||||
|
||||
var searchType = _getSearchType(searchTerm);
|
||||
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() => ref
|
||||
.read(searchResultPageProvider.notifier)
|
||||
.search(searchType.searchTerm, smartSearch: searchType.isSmart),
|
||||
);
|
||||
return () => searchFocusNode?.dispose();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
Future<void> onSearchSubmitted(String newSearchTerm) {
|
||||
debugPrint("Re-Search with $newSearchTerm");
|
||||
searchFocusNode?.unfocus();
|
||||
isNewSearch.value = false;
|
||||
currentSearchTerm.value = newSearchTerm;
|
||||
var searchType = _getSearchType(newSearchTerm);
|
||||
return ref
|
||||
.watch(searchResultPageProvider.notifier)
|
||||
.search(searchType.searchTerm, smartSearch: searchType.isSmart);
|
||||
}
|
||||
|
||||
buildTextField() {
|
||||
return TextField(
|
||||
controller: searchTermController,
|
||||
focusNode: searchFocusNode,
|
||||
autofocus: false,
|
||||
onTap: () {
|
||||
searchTermController.clear();
|
||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
|
||||
searchFocusNode?.requestFocus();
|
||||
},
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (searchTerm) {
|
||||
if (searchTerm.isNotEmpty) {
|
||||
searchTermController.clear();
|
||||
onSearchSubmitted(searchTerm);
|
||||
} else {
|
||||
isNewSearch.value = false;
|
||||
}
|
||||
},
|
||||
onChanged: (value) {
|
||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'search_result_page_new_search_hint'.tr(),
|
||||
enabledBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
focusedBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16.0,
|
||||
color: context.isDarkTheme
|
||||
? Colors.grey[500]
|
||||
: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildChip() {
|
||||
return Chip(
|
||||
label: Wrap(
|
||||
spacing: 5,
|
||||
runAlignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
currentSearchTerm.value,
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
Icon(
|
||||
Icons.close_rounded,
|
||||
color: context.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: context.primaryColor.withAlpha(50),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async => onSearchSubmitted(currentSearchTerm.value);
|
||||
|
||||
buildSearchResult() {
|
||||
final searchResultPageState = ref.watch(searchResultPageProvider);
|
||||
|
||||
if (searchResultPageState.isError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Text("common_server_error").tr(),
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResultPageState.isLoading) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
if (searchResultPageState.isSuccess) {
|
||||
return MultiselectGrid(
|
||||
renderListProvider: searchRenderListProvider,
|
||||
archiveEnabled: true,
|
||||
deleteEnabled: true,
|
||||
editEnabled: true,
|
||||
favoriteEnabled: true,
|
||||
stackEnabled: false,
|
||||
onRefresh: refresh,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
splashRadius: 20,
|
||||
onPressed: () {
|
||||
if (isNewSearch.value) {
|
||||
isNewSearch.value = false;
|
||||
} else {
|
||||
context.popRoute(true);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
title: GestureDetector(
|
||||
onTap: () {
|
||||
isNewSearch.value = true;
|
||||
searchFocusNode?.requestFocus();
|
||||
},
|
||||
child: isNewSearch.value ? buildTextField() : buildChip(),
|
||||
),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () {
|
||||
if (searchFocusNode != null) {
|
||||
searchFocusNode?.unfocus();
|
||||
}
|
||||
|
||||
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
buildSearchResult(),
|
||||
if (isNewSearch.value)
|
||||
SearchSuggestionList(onSubmitted: onSearchSubmitted),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -31,7 +31,9 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
|
||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||
import 'package:immich_mobile/modules/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,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -255,22 +255,21 @@ abstract class _$AppRouter extends RootStackRouter {
|
||||
child: const RecentlyAddedPage(),
|
||||
);
|
||||
},
|
||||
SearchRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<SearchRouteArgs>(
|
||||
orElse: () => const SearchRouteArgs());
|
||||
SearchInputRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<SearchInputRouteArgs>(
|
||||
orElse: () => const SearchInputRouteArgs());
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: SearchPage(key: args.key),
|
||||
child: SearchInputPage(
|
||||
key: args.key,
|
||||
prefilter: args.prefilter,
|
||||
),
|
||||
);
|
||||
},
|
||||
SearchResultRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<SearchResultRouteArgs>();
|
||||
SearchRoute.name: (routeData) {
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: SearchResultPage(
|
||||
key: args.key,
|
||||
searchTerm: args.searchTerm,
|
||||
),
|
||||
child: const SearchPage(),
|
||||
);
|
||||
},
|
||||
SelectAdditionalUserForSharingRoute.name: (routeData) {
|
||||
@ -1113,69 +1112,55 @@ class RecentlyAddedRoute extends PageRouteInfo<void> {
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SearchPage]
|
||||
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
|
||||
SearchRoute({
|
||||
/// [SearchInputPage]
|
||||
class SearchInputRoute extends PageRouteInfo<SearchInputRouteArgs> {
|
||||
SearchInputRoute({
|
||||
Key? key,
|
||||
SearchFilter? prefilter,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
SearchInputRoute.name,
|
||||
args: SearchInputRouteArgs(
|
||||
key: key,
|
||||
prefilter: prefilter,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'SearchInputRoute';
|
||||
|
||||
static const PageInfo<SearchInputRouteArgs> page =
|
||||
PageInfo<SearchInputRouteArgs>(name);
|
||||
}
|
||||
|
||||
class SearchInputRouteArgs {
|
||||
const SearchInputRouteArgs({
|
||||
this.key,
|
||||
this.prefilter,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final SearchFilter? prefilter;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchInputRouteArgs{key: $key, prefilter: $prefilter}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SearchPage]
|
||||
class SearchRoute extends PageRouteInfo<void> {
|
||||
const SearchRoute({List<PageRouteInfo>? children})
|
||||
: super(
|
||||
SearchRoute.name,
|
||||
args: SearchRouteArgs(key: key),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'SearchRoute';
|
||||
|
||||
static const PageInfo<SearchRouteArgs> page = PageInfo<SearchRouteArgs>(name);
|
||||
}
|
||||
|
||||
class SearchRouteArgs {
|
||||
const SearchRouteArgs({this.key});
|
||||
|
||||
final Key? key;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchRouteArgs{key: $key}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SearchResultPage]
|
||||
class SearchResultRoute extends PageRouteInfo<SearchResultRouteArgs> {
|
||||
SearchResultRoute({
|
||||
Key? key,
|
||||
required String searchTerm,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
SearchResultRoute.name,
|
||||
args: SearchResultRouteArgs(
|
||||
key: key,
|
||||
searchTerm: searchTerm,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'SearchResultRoute';
|
||||
|
||||
static const PageInfo<SearchResultRouteArgs> page =
|
||||
PageInfo<SearchResultRouteArgs>(name);
|
||||
}
|
||||
|
||||
class SearchResultRouteArgs {
|
||||
const SearchResultRouteArgs({
|
||||
this.key,
|
||||
required this.searchTerm,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String searchTerm;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchResultRouteArgs{key: $key, searchTerm: $searchTerm}';
|
||||
}
|
||||
static const PageInfo<void> page = PageInfo<void>(name);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
|
@ -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') {
|
||||
|
@ -43,6 +43,7 @@ class MultiselectGrid extends HookConsumerWidget {
|
||||
this.editEnabled = false,
|
||||
this.unarchive = false,
|
||||
this.unfavorite = false,
|
||||
this.emptyIndicator,
|
||||
});
|
||||
|
||||
final ProviderListenable<AsyncValue<RenderList>> 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) {
|
||||
|
@ -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(
|
||||
|
@ -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',
|
||||
|
3624
mobile/pubspec.lock
3624
mobile/pubspec.lock
File diff suppressed because it is too large
Load Diff
29
server/src/cores/storage.core.spec.ts
Normal file
29
server/src/cores/storage.core.spec.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
|
||||
jest.mock('src/constants', () => ({
|
||||
APP_MEDIA_LOCATION: '/photos',
|
||||
}));
|
||||
|
||||
describe('StorageCore', () => {
|
||||
describe('isImmichPath', () => {
|
||||
it('should return true for APP_MEDIA_LOCATION path', () => {
|
||||
const immichPath = '/photos';
|
||||
expect(StorageCore.isImmichPath(immichPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for paths within the APP_MEDIA_LOCATION', () => {
|
||||
const immichPath = '/photos/new/';
|
||||
expect(StorageCore.isImmichPath(immichPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for paths outside the APP_MEDIA_LOCATION and same starts', () => {
|
||||
const nonImmichPath = '/photos_new';
|
||||
expect(StorageCore.isImmichPath(nonImmichPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for paths outside the APP_MEDIA_LOCATION', () => {
|
||||
const nonImmichPath = '/some/other/path';
|
||||
expect(StorageCore.isImmichPath(nonImmichPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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 })
|
||||
|
4
server/src/services/asset.service.spec.ts
Normal file → Executable file
4
server/src/services/asset.service.spec.ts
Normal file → Executable file
@ -311,13 +311,15 @@ describe(AssetService.name, () => {
|
||||
const image1 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 0, 0, 0) };
|
||||
const image2 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 1, 0, 0) };
|
||||
const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) };
|
||||
const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) };
|
||||
|
||||
partnerMock.getAll.mockResolvedValue([]);
|
||||
assetMock.getByDayOfYear.mockResolvedValue([image1, image2, image3]);
|
||||
assetMock.getByDayOfYear.mockResolvedValue([image1, image2, image3, image4]);
|
||||
|
||||
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
|
||||
{ yearsAgo: 1, title: '1 year since...', assets: [mapAsset(image1), mapAsset(image2)] },
|
||||
{ yearsAgo: 9, title: '9 years since...', assets: [mapAsset(image3)] },
|
||||
{ yearsAgo: 15, title: '15 years since...', assets: [mapAsset(image4)] },
|
||||
]);
|
||||
|
||||
expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
|
||||
|
@ -184,7 +184,7 @@ export class AssetService {
|
||||
|
||||
return Object.keys(groups)
|
||||
.map(Number)
|
||||
.sort()
|
||||
.sort((a, b) => a - b)
|
||||
.filter((yearsAgo) => yearsAgo > 0)
|
||||
.map((yearsAgo) => ({
|
||||
yearsAgo,
|
||||
|
@ -75,10 +75,13 @@ describe('mimeTypes', () => {
|
||||
{ mimetype: 'image/x-sony-srf', extension: '.srf' },
|
||||
{ mimetype: 'image/x3f', extension: '.x3f' },
|
||||
{ mimetype: 'video/3gpp', extension: '.3gp' },
|
||||
{ mimetype: 'video/3gpp', extension: '.3gpp' },
|
||||
{ mimetype: 'video/avi', extension: '.avi' },
|
||||
{ mimetype: 'video/mp2t', extension: '.m2ts' },
|
||||
{ mimetype: 'video/mp2t', extension: '.mts' },
|
||||
{ mimetype: 'video/mp4', extension: '.mp4' },
|
||||
{ mimetype: 'video/mpeg', extension: '.mpe' },
|
||||
{ mimetype: 'video/mpeg', extension: '.mpeg' },
|
||||
{ mimetype: 'video/mpeg', extension: '.mpg' },
|
||||
{ mimetype: 'video/msvideo', extension: '.avi' },
|
||||
{ mimetype: 'video/quicktime', extension: '.mov' },
|
||||
|
@ -56,6 +56,7 @@ const profile: Record<string, string[]> = Object.fromEntries(
|
||||
|
||||
const video: Record<string, string[]> = {
|
||||
'.3gp': ['video/3gpp'],
|
||||
'.3gpp': ['video/3gpp'],
|
||||
'.avi': ['video/avi', 'video/msvideo', 'video/vnd.avi', 'video/x-msvideo'],
|
||||
'.flv': ['video/x-flv'],
|
||||
'.insv': ['video/mp4'],
|
||||
@ -64,6 +65,8 @@ const video: Record<string, string[]> = {
|
||||
'.mkv': ['video/x-matroska'],
|
||||
'.mov': ['video/quicktime'],
|
||||
'.mp4': ['video/mp4'],
|
||||
'.mpe': ['video/mpeg'],
|
||||
'.mpeg': ['video/mpeg'],
|
||||
'.mpg': ['video/mpeg'],
|
||||
'.mts': ['video/mp2t'],
|
||||
'.webm': ['video/webm'],
|
||||
|
@ -250,7 +250,7 @@
|
||||
<div class="px-3">
|
||||
<p>OFFLINE PATHS {orphans.length > 0 ? `(${orphans.length})` : ''}</p>
|
||||
<p class="text-gray-600 dark:text-gray-300 mt-1">
|
||||
These files are the results of manually deletion of the default upload library
|
||||
These results may be due to manual deletion of files in the default upload library
|
||||
</p>
|
||||
</div>
|
||||
</th>
|
||||
|
Loading…
x
Reference in New Issue
Block a user