mirror of
https://github.com/immich-app/immich.git
synced 2025-12-16 01:10:57 +03:00
Compare commits
3 Commits
refactor-l
...
feat/sync-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9279485ff | ||
|
|
6cf388af25 | ||
|
|
d1845e498e |
2
.github/.nvmrc
vendored
2
.github/.nvmrc
vendored
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.11.0
|
||||
|
||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:73a05fc805dfd3bd29bebc08442aedfec5c419c5ad3421ec73edc5647233891a
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "mich"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
name: Build and Push Server
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
2
.github/workflows/merge-translations.yml
vendored
2
.github/workflows/merge-translations.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
6
.github/workflows/prepare-release.yml
vendored
6
.github/workflows/prepare-release.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
6
.github/workflows/release-pr.yml
vendored
6
.github/workflows/release-pr.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Create PR
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -571,7 +571,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||
# with:
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -52,7 +52,7 @@
|
||||
},
|
||||
"cSpell.words": ["immich"],
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||
"eslint.validate": ["javascript", "svelte"],
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.11.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
|
||||
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY package* pnpm* .pnpmfile.cjs ./
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^22.19.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
@@ -69,6 +69,6 @@
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.1"
|
||||
"node": "24.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('crawl', () => {
|
||||
.map(([file]) => file);
|
||||
|
||||
// Compare file's content instead of path since a file can be represent in multiple ways.
|
||||
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
|
||||
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
|
||||
ignore: [`**/${exclusionPattern}`],
|
||||
});
|
||||
globbedFiles.push(...crawledFiles);
|
||||
return globbedFiles.toSorted();
|
||||
return globbedFiles.sort();
|
||||
};
|
||||
|
||||
export const sha1 = (filepath: string) => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2023",
|
||||
"target": "es2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.93.10"
|
||||
opentofu = "1.10.7"
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -135,7 +135,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -95,7 +95,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
|
||||
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.11.0
|
||||
|
||||
@@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
|
||||
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
|
||||
the job may not have run automatically the first time.
|
||||
|
||||
### How can I hide a photo or video from the timeline?
|
||||
### How can I hide photos from the timeline?
|
||||
|
||||
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
|
||||
You can _archive_ them.
|
||||
|
||||
### How can I backup data from Immich?
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ make e2e
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
- `pnpm install` (in `e2e/`)
|
||||
- `pnpm run build` (in `cli/`)
|
||||
- `make open-api` (in the project root `/`)
|
||||
|
||||
Once the test environment is running, the e2e tests can be run via:
|
||||
|
||||
@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
|
||||
## Ports
|
||||
|
||||
| Variable | Description | Default | Containers |
|
||||
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
|
||||
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
|
||||
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
|
||||
| Variable | Description | Default |
|
||||
| :------------ | :------------- | :----------------------------------------: |
|
||||
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
|
||||
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
|
||||
|
||||
## Database
|
||||
|
||||
@@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
|
||||
|
||||
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
||||
|
||||
|
||||
@@ -57,6 +57,6 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.1"
|
||||
"node": "24.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.11.0
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/oidc-provider": "^9.0.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
@@ -35,8 +35,8 @@
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"exiftool-vendored": "^33.0.0",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"exiftool-vendored": "^31.1.0",
|
||||
"globals": "^16.0.0",
|
||||
"jose": "^5.6.3",
|
||||
"luxon": "^3.4.4",
|
||||
@@ -45,7 +45,7 @@
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sharp": "^0.34.4",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
@@ -54,6 +54,6 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.1"
|
||||
"node": "24.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedDays].toSorted((a, b) => b - a);
|
||||
return [...selectedDays].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,60 +62,50 @@ export const setupTimelineMockApiRoutes = async (
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
const asset = getAsset(timelineRestData, assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/ocr', async (route) => {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
await context.route('**/api/assets/**', async (route, request) => {
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||
if (!match) {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
const asset = getAsset(timelineRestData, assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
}
|
||||
|
||||
if (match.groups.size === 'preview') {
|
||||
if (match.groups?.size === 'preview') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||
body: await randomPreview(
|
||||
match.groups.assetId,
|
||||
match.groups?.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (match.groups.size === 'thumbnail') {
|
||||
if (match.groups?.size === 'thumbnail') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail(
|
||||
match.groups.assetId,
|
||||
match.groups?.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/albums/**', async (route, request) => {
|
||||
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
||||
const match = request.url().match(pattern);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PersonCreateDto,
|
||||
QueueCommandDto,
|
||||
QueueName,
|
||||
QueuesResponseLegacyDto,
|
||||
QueuesResponseDto,
|
||||
SharedLinkCreateDto,
|
||||
UpdateLibraryDto,
|
||||
UserAdminCreateDto,
|
||||
@@ -564,13 +564,13 @@ export const utils = {
|
||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
|
||||
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
||||
const jobCounts = queues[queue].jobCounts;
|
||||
return !jobCounts.active && !jobCounts.waiting;
|
||||
},
|
||||
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
||||
|
||||
@@ -611,53 +611,6 @@ test.describe('Timeline', () => {
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
|
||||
const assetToFavorite = assets[0];
|
||||
changes.assetArchivals.push(assetToFavorite.id);
|
||||
await pageUtils.openArchivePage(page);
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
|
||||
});
|
||||
test('open album, archive photo, open album, unarchive', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
@@ -680,7 +633,8 @@ test.describe('Timeline', () => {
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
|
||||
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
@@ -702,7 +656,8 @@ test.describe('Timeline', () => {
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
|
||||
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
@@ -757,50 +712,6 @@ test.describe('Timeline', () => {
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
||||
await pageUtils.openFavorites(page);
|
||||
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
});
|
||||
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
|
||||
@@ -105,16 +105,20 @@ export const thumbnailUtils = {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
|
||||
},
|
||||
async expectThumbnailIsFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
|
||||
await expect(
|
||||
thumbnailUtils
|
||||
.withAssetId(page, assetId)
|
||||
.locator(
|
||||
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
await expect(
|
||||
thumbnailUtils
|
||||
.withAssetId(page, assetId)
|
||||
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
|
||||
).toHaveCount(1);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
@@ -204,18 +208,10 @@ export const pageUtils = {
|
||||
await page.goto(`/photos`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openFavorites(page: Page) {
|
||||
await page.goto(`/favorites`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openAlbumPage(page: Page, albumId: string) {
|
||||
await page.goto(`/albums/${albumId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openArchivePage(page: Page) {
|
||||
await page.goto(`/archive`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
||||
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await page.getByText('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
@@ -83,7 +83,7 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await page.getByText('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2023",
|
||||
"target": "es2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
|
||||
14
i18n/en.json
14
i18n/en.json
@@ -67,7 +67,6 @@
|
||||
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
|
||||
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
|
||||
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
|
||||
"copy_config_to_clipboard_description": "Copy the current system config as a JSON object to the clipboard",
|
||||
"create_job": "Create job",
|
||||
"cron_expression": "Cron expression",
|
||||
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
||||
@@ -75,8 +74,6 @@
|
||||
"disable_login": "Disable login",
|
||||
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
|
||||
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
||||
"export_config_as_json_description": "Download the current system config as a JSON file",
|
||||
"external_libraries_page_description": "Admin external library page",
|
||||
"external_library_management": "External Library Management",
|
||||
"face_detection": "Face detection",
|
||||
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
|
||||
@@ -105,7 +102,6 @@
|
||||
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
|
||||
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
|
||||
"image_thumbnail_title": "Thumbnail Settings",
|
||||
"import_config_from_json_description": "Import system config by uploading a JSON config file",
|
||||
"job_concurrency": "{job} concurrency",
|
||||
"job_created": "Job created",
|
||||
"job_not_concurrency_safe": "This job is not concurrency-safe.",
|
||||
@@ -114,7 +110,6 @@
|
||||
"job_status": "Job Status",
|
||||
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
|
||||
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
||||
"jobs_page_description": "Admin jobs page",
|
||||
"library_created": "Created library: {library}",
|
||||
"library_deleted": "Library deleted",
|
||||
"library_details": "Library details",
|
||||
@@ -187,7 +182,6 @@
|
||||
"maintenance_start": "Start maintenance mode",
|
||||
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||
"manage_concurrency": "Manage Concurrency",
|
||||
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
||||
"manage_log_settings": "Manage log settings",
|
||||
"map_dark_style": "Dark style",
|
||||
"map_enable_description": "Enable map features",
|
||||
@@ -293,10 +287,8 @@
|
||||
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
|
||||
"server_settings": "Server Settings",
|
||||
"server_settings_description": "Manage server settings",
|
||||
"server_stats_page_description": "Admin server statistics page",
|
||||
"server_welcome_message": "Welcome message",
|
||||
"server_welcome_message_description": "A message that is displayed on the login page.",
|
||||
"settings_page_description": "Admin settings page",
|
||||
"sidecar_job": "Sidecar metadata",
|
||||
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
|
||||
"slideshow_duration_description": "Number of seconds to display each image",
|
||||
@@ -415,8 +407,6 @@
|
||||
"user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}",
|
||||
"user_settings": "User Settings",
|
||||
"user_settings_description": "Manage user settings",
|
||||
"user_successfully_removed": "User {email} has been successfully removed.",
|
||||
"users_page_description": "Admin users page",
|
||||
"version_check_enabled_description": "Enable version check",
|
||||
"version_check_implications": "The version check feature relies on periodic communication with github.com",
|
||||
"version_check_settings": "Version Check",
|
||||
@@ -737,7 +727,6 @@
|
||||
"collapse_all": "Collapse all",
|
||||
"color": "Color",
|
||||
"color_theme": "Color theme",
|
||||
"command": "Command",
|
||||
"comment_deleted": "Comment deleted",
|
||||
"comment_options": "Comment options",
|
||||
"comments_and_likes": "Comments & likes",
|
||||
@@ -1522,7 +1511,6 @@
|
||||
"other_variables": "Other variables",
|
||||
"owned": "Owned",
|
||||
"owner": "Owner",
|
||||
"page": "Page",
|
||||
"partner": "Partner",
|
||||
"partner_can_access": "{partner} can access",
|
||||
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
|
||||
@@ -2083,7 +2071,6 @@
|
||||
"to_select": "to select",
|
||||
"to_trash": "Trash",
|
||||
"toggle_settings": "Toggle settings",
|
||||
"toggle_theme_description": "Toggle theme",
|
||||
"total": "Total",
|
||||
"total_usage": "Total usage",
|
||||
"trash": "Trash",
|
||||
@@ -2192,7 +2179,6 @@
|
||||
"view_album": "View Album",
|
||||
"view_all": "View All",
|
||||
"view_all_users": "View all users",
|
||||
"view_asset_owners": "View asset owners",
|
||||
"view_details": "View Details",
|
||||
"view_in_timeline": "View in timeline",
|
||||
"view_link": "View link",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu
|
||||
|
||||
FROM builder-cpu AS builder-openvino
|
||||
|
||||
@@ -22,10 +22,10 @@ FROM builder-cpu AS builder-rknn
|
||||
|
||||
# Warning: 25GiB+ disk space required to pull this image
|
||||
# TODO: find a way to reduce the image size
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
|
||||
|
||||
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
||||
ARG ONNXRUNTIME_VERSION="v1.22.1"
|
||||
ARG ONNXRUNTIME_VERSION="v1.20.1"
|
||||
WORKDIR /code
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
|
||||
@@ -68,12 +68,12 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
|
||||
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
||||
fi
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
|
||||
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
@@ -102,7 +102,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
|
||||
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
||||
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
||||
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
|
||||
|
||||
FROM prod-cpu AS prod-armnn
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ class TextDetector(InferenceModel):
|
||||
ratio = float(self.max_resolution) / img.height
|
||||
else:
|
||||
ratio = float(self.max_resolution) / img.width
|
||||
ratio = min(ratio, 1.0)
|
||||
|
||||
resize_h = int(img.height * ratio)
|
||||
resize_w = int(img.width * ratio)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
|
||||
index 2714e6f59..a69da76b4 100644
|
||||
index d90a2a355..bb1a7de12 100644
|
||||
--- a/cmake/CMakeLists.txt
|
||||
+++ b/cmake/CMakeLists.txt
|
||||
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
|
||||
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
|
||||
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
|
||||
else()
|
||||
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
endif()
|
||||
@@ -295,7 +295,7 @@ if (onnxruntime_USE_ROCM)
|
||||
endif()
|
||||
|
||||
|
||||
if (NOT CMAKE_HIP_ARCHITECTURES)
|
||||
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
endif()
|
||||
|
||||
file(GLOB rocm_cmake_components ${onnxruntime_ROCM_HOME}/lib/cmake/*)
|
||||
|
||||
3598
machine-learning/uv.lock
generated
3598
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,11 @@
|
||||
experimental_monorepo_root = true
|
||||
|
||||
[tools]
|
||||
node = "24.11.1"
|
||||
node = "24.11.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.22.0"
|
||||
terragrunt = "0.93.10"
|
||||
opentofu = "1.10.7"
|
||||
java = "25.0.1"
|
||||
pnpm = "10.20.0"
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
||||
@@ -89,7 +89,8 @@ data class PlatformAsset (
|
||||
val height: Long? = null,
|
||||
val durationInSeconds: Long,
|
||||
val orientation: Long,
|
||||
val isFavorite: Boolean
|
||||
val isFavorite: Boolean,
|
||||
val adjustmentTimestamp: Long? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
@@ -104,7 +105,8 @@ data class PlatformAsset (
|
||||
val durationInSeconds = pigeonVar_list[7] as Long
|
||||
val orientation = pigeonVar_list[8] as Long
|
||||
val isFavorite = pigeonVar_list[9] as Boolean
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
|
||||
val adjustmentTimestamp = pigeonVar_list[10] as Long?
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTimestamp)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
@@ -119,6 +121,7 @@ data class PlatformAsset (
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
adjustmentTimestamp,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
|
||||
@@ -155,6 +155,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
duration,
|
||||
orientation.toLong(),
|
||||
isFavorite,
|
||||
adjustmentTimestamp = null
|
||||
)
|
||||
yield(AssetResult.ValidAsset(asset, bucketId))
|
||||
}
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v14.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v14.json
generated
Normal file
File diff suppressed because one or more lines are too long
@@ -140,6 +140,7 @@ struct PlatformAsset: Hashable {
|
||||
var durationInSeconds: Int64
|
||||
var orientation: Int64
|
||||
var isFavorite: Bool
|
||||
var adjustmentTimestamp: Int64? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
@@ -154,6 +155,7 @@ struct PlatformAsset: Hashable {
|
||||
let durationInSeconds = pigeonVar_list[7] as! Int64
|
||||
let orientation = pigeonVar_list[8] as! Int64
|
||||
let isFavorite = pigeonVar_list[9] as! Bool
|
||||
let adjustmentTimestamp: Int64? = nilOrValue(pigeonVar_list[10])
|
||||
|
||||
return PlatformAsset(
|
||||
id: id,
|
||||
@@ -165,7 +167,8 @@ struct PlatformAsset: Hashable {
|
||||
height: height,
|
||||
durationInSeconds: durationInSeconds,
|
||||
orientation: orientation,
|
||||
isFavorite: isFavorite
|
||||
isFavorite: isFavorite,
|
||||
adjustmentTimestamp: adjustmentTimestamp
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
@@ -180,6 +183,7 @@ struct PlatformAsset: Hashable {
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
adjustmentTimestamp,
|
||||
]
|
||||
}
|
||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||
|
||||
@@ -12,7 +12,8 @@ extension PHAsset {
|
||||
height: Int64(pixelHeight),
|
||||
durationInSeconds: Int64(duration),
|
||||
orientation: 0,
|
||||
isFavorite: isFavorite
|
||||
isFavorite: isFavorite,
|
||||
adjustmentTimestamp: adjustmentTimestamp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +24,10 @@ extension PHAsset {
|
||||
var filename: String? {
|
||||
return value(forKey: "filename") as? String
|
||||
}
|
||||
|
||||
var adjustmentTimestamp: Int64 {
|
||||
return (value(forKey: "adjustmentTimestamp") as? Date?).map( {Int64($0?.timeIntervalSince1970 ?? 0)} ) ?? 0
|
||||
}
|
||||
|
||||
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
|
||||
var originalFilename: String? {
|
||||
|
||||
@@ -4,6 +4,7 @@ class LocalAsset extends BaseAsset {
|
||||
final String id;
|
||||
final String? remoteAssetId;
|
||||
final int orientation;
|
||||
final int? adjustmentTimestamp;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
@@ -19,6 +20,7 @@ class LocalAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
this.adjustmentTimestamp,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -47,6 +49,7 @@ class LocalAsset extends BaseAsset {
|
||||
remoteId: ${remoteId ?? "<NA>"}
|
||||
isFavorite: $isFavorite,
|
||||
orientation: $orientation,
|
||||
adjustmentTimestamp: ${adjustmentTimestamp ?? "<NA>"}
|
||||
}''';
|
||||
}
|
||||
|
||||
@@ -55,11 +58,15 @@ class LocalAsset extends BaseAsset {
|
||||
bool operator ==(Object other) {
|
||||
if (other is! LocalAsset) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return super == other && id == other.id && orientation == other.orientation;
|
||||
return super == other &&
|
||||
id == other.id &&
|
||||
orientation == other.orientation &&
|
||||
adjustmentTimestamp == other.adjustmentTimestamp;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
|
||||
int get hashCode =>
|
||||
super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode ^ adjustmentTimestamp.hashCode;
|
||||
|
||||
LocalAsset copyWith({
|
||||
String? id,
|
||||
@@ -74,6 +81,7 @@ class LocalAsset extends BaseAsset {
|
||||
int? durationInSeconds,
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
int? adjustmentTimestamp,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -88,6 +96,7 @@ class LocalAsset extends BaseAsset {
|
||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
adjustmentTimestamp: adjustmentTimestamp ?? this.adjustmentTimestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
// Timeline Events
|
||||
class TimelineReloadEvent extends Event {
|
||||
const TimelineReloadEvent();
|
||||
}
|
||||
|
||||
class ScrollToTopEvent extends Event {
|
||||
const ScrollToTopEvent();
|
||||
}
|
||||
|
||||
class ScrollToDateEvent extends Event {
|
||||
final DateTime date;
|
||||
|
||||
const ScrollToDateEvent(this.date);
|
||||
}
|
||||
|
||||
// Asset Viewer Events
|
||||
class ViewerOpenBottomSheetEvent extends Event {
|
||||
final bool activitiesMode;
|
||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||
}
|
||||
|
||||
class ViewerReloadAssetEvent extends Event {
|
||||
const ViewerReloadAssetEvent();
|
||||
}
|
||||
|
||||
// Multi-Select Events
|
||||
class MultiSelectToggleEvent extends Event {
|
||||
final bool isEnabled;
|
||||
const MultiSelectToggleEvent(this.isEnabled);
|
||||
}
|
||||
@@ -71,7 +71,6 @@ enum StoreKey<T> {
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
|
||||
autoPlayVideo<bool>._(139),
|
||||
albumGridView<bool>._(140),
|
||||
|
||||
// Experimental stuff
|
||||
photoManagerCustomFilter<bool>._(1000),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
enum GroupAssetsBy { day, month, auto, none }
|
||||
|
||||
enum HeaderType { none, month, day, monthAndDay }
|
||||
@@ -29,3 +31,17 @@ class TimeBucket extends Bucket {
|
||||
@override
|
||||
int get hashCode => super.hashCode ^ date.hashCode;
|
||||
}
|
||||
|
||||
class TimelineReloadEvent extends Event {
|
||||
const TimelineReloadEvent();
|
||||
}
|
||||
|
||||
class ScrollToTopEvent extends Event {
|
||||
const ScrollToTopEvent();
|
||||
}
|
||||
|
||||
class ScrollToDateEvent extends Event {
|
||||
final DateTime date;
|
||||
|
||||
const ScrollToDateEvent(this.date);
|
||||
}
|
||||
|
||||
@@ -371,6 +371,7 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
|
||||
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
adjustmentTimestamp: adjustmentTimestamp,
|
||||
width: width,
|
||||
height: height,
|
||||
durationInSeconds: durationInSeconds,
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
class RemoteAlbumService {
|
||||
final DriftRemoteAlbumRepository _repository;
|
||||
@@ -33,16 +32,16 @@ class RemoteAlbumService {
|
||||
|
||||
Future<List<RemoteAlbum>> sortAlbums(
|
||||
List<RemoteAlbum> albums,
|
||||
AlbumSortMode sortMode, {
|
||||
RemoteAlbumSortMode sortMode, {
|
||||
bool isReverse = false,
|
||||
}) async {
|
||||
final List<RemoteAlbum> sorted = switch (sortMode) {
|
||||
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
||||
AlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
||||
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||
};
|
||||
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
@@ -212,3 +211,16 @@ class RemoteAlbumService {
|
||||
return sorted.reversed.toList();
|
||||
}
|
||||
}
|
||||
|
||||
enum RemoteAlbumSortMode {
|
||||
title("library_page_sort_title"),
|
||||
assetCount("library_page_sort_asset_count"),
|
||||
lastModified("library_page_sort_last_modified"),
|
||||
created("library_page_sort_created"),
|
||||
mostRecent("sort_newest"),
|
||||
mostOldest("sort_oldest");
|
||||
|
||||
final String key;
|
||||
|
||||
const RemoteAlbumSortMode(this.key);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:math' as math;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:timezone/timezone.dart';
|
||||
|
||||
extension TZExtension on Asset {
|
||||
/// Returns the created time of the asset from the exif info (if available) or from
|
||||
@@ -7,11 +7,24 @@ extension TZExtension on Asset {
|
||||
/// the timezone offset in [Duration]
|
||||
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
|
||||
DateTime dt = fileCreatedAt.toLocal();
|
||||
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone);
|
||||
dt = exifInfo!.dateTimeOriginal!;
|
||||
if (exifInfo?.timeZone != null) {
|
||||
dt = dt.toUtc();
|
||||
try {
|
||||
final location = getLocation(exifInfo!.timeZone!);
|
||||
dt = TZDateTime.from(dt, location);
|
||||
} on LocationNotFoundException {
|
||||
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
|
||||
final m = re.firstMatch(exifInfo!.timeZone!);
|
||||
if (m != null) {
|
||||
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
|
||||
dt = dt.add(duration);
|
||||
return (dt, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
IntColumn get orientation => integer().withDefault(const Constant(0))();
|
||||
|
||||
IntColumn get adjustmentTimestamp => integer().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -34,5 +36,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
width: width,
|
||||
remoteId: remoteId,
|
||||
orientation: orientation,
|
||||
adjustmentTimestamp: adjustmentTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<String?> checksum,
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
i0.Value<int?> adjustmentTimestamp,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -35,6 +36,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<String?> checksum,
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
i0.Value<int?> adjustmentTimestamp,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -101,6 +103,11 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.orientation,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<int> get adjustmentTimestamp => $composableBuilder(
|
||||
column: $table.adjustmentTimestamp,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -166,6 +173,11 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.orientation,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<int> get adjustmentTimestamp => $composableBuilder(
|
||||
column: $table.adjustmentTimestamp,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -215,6 +227,11 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
column: $table.orientation,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<int> get adjustmentTimestamp => $composableBuilder(
|
||||
column: $table.adjustmentTimestamp,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -268,6 +285,7 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||
i0.Value<int> orientation = const i0.Value.absent(),
|
||||
i0.Value<int?> adjustmentTimestamp = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -280,6 +298,7 @@ class $$LocalAssetEntityTableTableManager
|
||||
checksum: checksum,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
adjustmentTimestamp: adjustmentTimestamp,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -294,6 +313,7 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||
i0.Value<int> orientation = const i0.Value.absent(),
|
||||
i0.Value<int?> adjustmentTimestamp = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -306,6 +326,7 @@ class $$LocalAssetEntityTableTableManager
|
||||
checksum: checksum,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
adjustmentTimestamp: adjustmentTimestamp,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -473,6 +494,17 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const i4.Constant(0),
|
||||
);
|
||||
static const i0.VerificationMeta _adjustmentTimestampMeta =
|
||||
const i0.VerificationMeta('adjustmentTimestamp');
|
||||
@override
|
||||
late final i0.GeneratedColumn<int> adjustmentTimestamp =
|
||||
i0.GeneratedColumn<int>(
|
||||
'adjustment_timestamp',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
@@ -486,6 +518,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
checksum,
|
||||
isFavorite,
|
||||
orientation,
|
||||
adjustmentTimestamp,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -566,6 +599,15 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('adjustment_timestamp')) {
|
||||
context.handle(
|
||||
_adjustmentTimestampMeta,
|
||||
adjustmentTimestamp.isAcceptableOrUnknown(
|
||||
data['adjustment_timestamp']!,
|
||||
_adjustmentTimestampMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -624,6 +666,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}orientation'],
|
||||
)!,
|
||||
adjustmentTimestamp: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}adjustment_timestamp'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -653,6 +699,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final String? checksum;
|
||||
final bool isFavorite;
|
||||
final int orientation;
|
||||
final int? adjustmentTimestamp;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -665,6 +712,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.checksum,
|
||||
required this.isFavorite,
|
||||
required this.orientation,
|
||||
this.adjustmentTimestamp,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -692,6 +740,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
}
|
||||
map['is_favorite'] = i0.Variable<bool>(isFavorite);
|
||||
map['orientation'] = i0.Variable<int>(orientation);
|
||||
if (!nullToAbsent || adjustmentTimestamp != null) {
|
||||
map['adjustment_timestamp'] = i0.Variable<int>(adjustmentTimestamp);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -714,6 +765,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
checksum: serializer.fromJson<String?>(json['checksum']),
|
||||
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
|
||||
orientation: serializer.fromJson<int>(json['orientation']),
|
||||
adjustmentTimestamp: serializer.fromJson<int?>(
|
||||
json['adjustmentTimestamp'],
|
||||
),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -733,6 +787,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'checksum': serializer.toJson<String?>(checksum),
|
||||
'isFavorite': serializer.toJson<bool>(isFavorite),
|
||||
'orientation': serializer.toJson<int>(orientation),
|
||||
'adjustmentTimestamp': serializer.toJson<int?>(adjustmentTimestamp),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -748,6 +803,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
i0.Value<int?> adjustmentTimestamp = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -762,6 +818,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
checksum: checksum.present ? checksum.value : this.checksum,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
adjustmentTimestamp: adjustmentTimestamp.present
|
||||
? adjustmentTimestamp.value
|
||||
: this.adjustmentTimestamp,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -782,6 +841,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
orientation: data.orientation.present
|
||||
? data.orientation.value
|
||||
: this.orientation,
|
||||
adjustmentTimestamp: data.adjustmentTimestamp.present
|
||||
? data.adjustmentTimestamp.value
|
||||
: this.adjustmentTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -798,7 +860,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('id: $id, ')
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('adjustmentTimestamp: $adjustmentTimestamp')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -816,6 +879,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
checksum,
|
||||
isFavorite,
|
||||
orientation,
|
||||
adjustmentTimestamp,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -831,7 +895,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.id == this.id &&
|
||||
other.checksum == this.checksum &&
|
||||
other.isFavorite == this.isFavorite &&
|
||||
other.orientation == this.orientation);
|
||||
other.orientation == this.orientation &&
|
||||
other.adjustmentTimestamp == this.adjustmentTimestamp);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -847,6 +912,7 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<String?> checksum;
|
||||
final i0.Value<bool> isFavorite;
|
||||
final i0.Value<int> orientation;
|
||||
final i0.Value<int?> adjustmentTimestamp;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -859,6 +925,7 @@ class LocalAssetEntityCompanion
|
||||
this.checksum = const i0.Value.absent(),
|
||||
this.isFavorite = const i0.Value.absent(),
|
||||
this.orientation = const i0.Value.absent(),
|
||||
this.adjustmentTimestamp = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -872,6 +939,7 @@ class LocalAssetEntityCompanion
|
||||
this.checksum = const i0.Value.absent(),
|
||||
this.isFavorite = const i0.Value.absent(),
|
||||
this.orientation = const i0.Value.absent(),
|
||||
this.adjustmentTimestamp = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -887,6 +955,7 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<String>? checksum,
|
||||
i0.Expression<bool>? isFavorite,
|
||||
i0.Expression<int>? orientation,
|
||||
i0.Expression<int>? adjustmentTimestamp,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -900,6 +969,8 @@ class LocalAssetEntityCompanion
|
||||
if (checksum != null) 'checksum': checksum,
|
||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||
if (orientation != null) 'orientation': orientation,
|
||||
if (adjustmentTimestamp != null)
|
||||
'adjustment_timestamp': adjustmentTimestamp,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -915,6 +986,7 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<String?>? checksum,
|
||||
i0.Value<bool>? isFavorite,
|
||||
i0.Value<int>? orientation,
|
||||
i0.Value<int?>? adjustmentTimestamp,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -928,6 +1000,7 @@ class LocalAssetEntityCompanion
|
||||
checksum: checksum ?? this.checksum,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
adjustmentTimestamp: adjustmentTimestamp ?? this.adjustmentTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -969,6 +1042,9 @@ class LocalAssetEntityCompanion
|
||||
if (orientation.present) {
|
||||
map['orientation'] = i0.Variable<int>(orientation.value);
|
||||
}
|
||||
if (adjustmentTimestamp.present) {
|
||||
map['adjustment_timestamp'] = i0.Variable<int>(adjustmentTimestamp.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -985,7 +1061,8 @@ class LocalAssetEntityCompanion
|
||||
..write('id: $id, ')
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('adjustmentTimestamp: $adjustmentTimestamp')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
|
||||
@@ -21,6 +20,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
||||
@@ -95,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 13;
|
||||
int get schemaVersion => 14;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -185,6 +185,9 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
|
||||
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
|
||||
},
|
||||
from13To14: (m, v14) async {
|
||||
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTimestamp);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -5485,6 +5485,456 @@ i1.GeneratedColumn<String> _column_95(String aliasedName) =>
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
);
|
||||
|
||||
final class Schema14 extends i0.VersionedSchema {
|
||||
Schema14({required super.database}) : super(version: 14);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAssetChecksum,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
idxLatLng,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape17 remoteAssetEntity = Shape17(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape24 localAssetEntity = Shape24(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_96,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape15 assetFaceEntity = Shape15(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape23 trashedLocalAssetEntity = Shape23(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape24 extends i0.VersionedTable {
|
||||
Shape24({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationInSeconds =>
|
||||
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get adjustmentTimestamp =>
|
||||
columnsByName['adjustment_timestamp']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_96(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'adjustment_timestamp',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.int,
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -5498,6 +5948,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -5561,6 +6012,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from12To13(migrator, schema);
|
||||
return 13;
|
||||
case 13:
|
||||
final schema = Schema14(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from13To14(migrator, schema);
|
||||
return 14;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -5580,6 +6036,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -5594,5 +6051,6 @@ i1.OnUpgrade stepByStep({
|
||||
from10To11: from10To11,
|
||||
from11To12: from11To12,
|
||||
from12To13: from12To13,
|
||||
from13To14: from13To14,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -262,6 +262,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
id: asset.id,
|
||||
orientation: Value(asset.orientation),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
adjustmentTimestamp: Value(asset.adjustmentTimestamp),
|
||||
);
|
||||
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||
_db.localAssetEntity,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
class OAuthLoginData {
|
||||
final String serverUrl;
|
||||
final String state;
|
||||
final String codeVerifier;
|
||||
|
||||
const OAuthLoginData({required this.serverUrl, required this.state, required this.codeVerifier});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
|
||||
@@ -58,7 +58,7 @@ class SettingsPage extends StatelessWidget {
|
||||
context.locale;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
|
||||
body: context.isMobile ? const _MobileLayout() : const _TabletLayout(),
|
||||
body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,11 @@ class _MobileLayout extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
|
||||
return ListView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: 10.0, bottom: 16),
|
||||
children: [...settings],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
@@ -16,6 +16,7 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
||||
@@ -27,7 +27,7 @@ class LoginPage extends HookConsumerWidget {
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: const LoginForm(),
|
||||
body: LoginForm(),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
|
||||
18
mobile/lib/platform/native_sync_api.g.dart
generated
18
mobile/lib/platform/native_sync_api.g.dart
generated
@@ -41,6 +41,7 @@ class PlatformAsset {
|
||||
required this.durationInSeconds,
|
||||
required this.orientation,
|
||||
required this.isFavorite,
|
||||
this.adjustmentTimestamp,
|
||||
});
|
||||
|
||||
String id;
|
||||
@@ -63,8 +64,22 @@ class PlatformAsset {
|
||||
|
||||
bool isFavorite;
|
||||
|
||||
int? adjustmentTimestamp;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite];
|
||||
return <Object?>[
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
width,
|
||||
height,
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
adjustmentTimestamp,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
@@ -84,6 +99,7 @@ class PlatformAsset {
|
||||
durationInSeconds: result[7]! as int,
|
||||
orientation: result[8]! as int,
|
||||
isFavorite: result[9]! as bool,
|
||||
adjustmentTimestamp: result[10] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
||||
properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString()));
|
||||
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
|
||||
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
|
||||
properties.add(_PropertyItem(label: 'Adjustment Timestamp', value: asset.adjustmentTimestamp?.toString()));
|
||||
}
|
||||
|
||||
Future<void> _addRemoteAssetProperties(RemoteAsset asset) async {
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -9,8 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
|
||||
// used to allow performing unarchive action from different sources (without duplicating code)
|
||||
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
@@ -16,9 +17,6 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/album_filter.utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
@@ -47,28 +45,14 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
List<RemoteAlbum> shownAlbums = [];
|
||||
|
||||
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
|
||||
AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true);
|
||||
AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Load albums when component mounts
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
|
||||
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
|
||||
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
|
||||
|
||||
final albumSortMode = AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == savedSortMode,
|
||||
orElse: () => AlbumSortMode.lastModified,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
|
||||
isGrid = savedIsGrid;
|
||||
});
|
||||
|
||||
ref.read(remoteAlbumProvider.notifier).refresh();
|
||||
});
|
||||
|
||||
@@ -98,7 +82,6 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
setState(() {
|
||||
isGrid = !isGrid;
|
||||
});
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
|
||||
}
|
||||
|
||||
void changeFilter(QuickFilterMode mode) {
|
||||
@@ -114,10 +97,6 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
this.sort = sort;
|
||||
});
|
||||
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
|
||||
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
|
||||
|
||||
await sortAlbums();
|
||||
}
|
||||
|
||||
@@ -202,8 +181,6 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
onToggleViewMode: toggleViewMode,
|
||||
onSortChanged: changeSort,
|
||||
controller: menuController,
|
||||
currentSortMode: sort.mode,
|
||||
currentIsReverse: sort.isReverse,
|
||||
),
|
||||
isGrid
|
||||
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
|
||||
@@ -215,46 +192,21 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
}
|
||||
|
||||
class _SortButton extends ConsumerStatefulWidget {
|
||||
const _SortButton(
|
||||
this.onSortChanged, {
|
||||
required this.initialSortMode,
|
||||
required this.initialIsReverse,
|
||||
this.controller,
|
||||
});
|
||||
const _SortButton(this.onSortChanged, {this.controller});
|
||||
|
||||
final Future<void> Function(AlbumSort) onSortChanged;
|
||||
final MenuController? controller;
|
||||
final AlbumSortMode initialSortMode;
|
||||
final bool initialIsReverse;
|
||||
|
||||
@override
|
||||
ConsumerState<_SortButton> createState() => _SortButtonState();
|
||||
}
|
||||
|
||||
class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
late AlbumSortMode albumSortOption;
|
||||
late bool albumSortIsReverse;
|
||||
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
|
||||
bool albumSortIsReverse = true;
|
||||
bool isSorting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
albumSortOption = widget.initialSortMode;
|
||||
albumSortIsReverse = widget.initialIsReverse;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_SortButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) {
|
||||
setState(() {
|
||||
albumSortOption = widget.initialSortMode;
|
||||
albumSortIsReverse = widget.initialIsReverse;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onMenuTapped(AlbumSortMode sortMode) async {
|
||||
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
|
||||
final selected = albumSortOption == sortMode;
|
||||
// Switch direction
|
||||
if (selected) {
|
||||
@@ -288,7 +240,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
|
||||
),
|
||||
consumeOutsideTap: true,
|
||||
menuChildren: AlbumSortMode.values
|
||||
menuChildren: RemoteAlbumSortMode.values
|
||||
.map(
|
||||
(sortMode) => MenuItemButton(
|
||||
leadingIcon: albumSortOption == sortMode
|
||||
@@ -317,7 +269,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
sortMode.label.t(context: context),
|
||||
sortMode.key.t(context: context),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: albumSortOption == sortMode
|
||||
@@ -346,7 +298,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
: const Icon(Icons.keyboard_arrow_up_rounded),
|
||||
),
|
||||
Text(
|
||||
albumSortOption.label.t(context: context),
|
||||
albumSortOption.key.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: context.colorScheme.onSurface.withAlpha(225),
|
||||
@@ -513,8 +465,6 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
||||
required this.isGrid,
|
||||
required this.onToggleViewMode,
|
||||
required this.onSortChanged,
|
||||
required this.currentSortMode,
|
||||
required this.currentIsReverse,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
@@ -522,8 +472,6 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
||||
final VoidCallback onToggleViewMode;
|
||||
final MenuController? controller;
|
||||
final Future<void> Function(AlbumSort) onSortChanged;
|
||||
final AlbumSortMode currentSortMode;
|
||||
final bool currentIsReverse;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -533,12 +481,7 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_SortButton(
|
||||
onSortChanged,
|
||||
controller: controller,
|
||||
initialSortMode: currentSortMode,
|
||||
initialIsReverse: currentIsReverse,
|
||||
),
|
||||
_SortButton(onSortChanged, controller: controller),
|
||||
IconButton(
|
||||
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
|
||||
onPressed: onToggleViewMode,
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class ViewerOpenBottomSheetEvent extends Event {
|
||||
final bool activitiesMode;
|
||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||
}
|
||||
|
||||
class ViewerReloadAssetEvent extends Event {
|
||||
const ViewerReloadAssetEvent();
|
||||
}
|
||||
|
||||
class AssetViewerState {
|
||||
final int backgroundOpacity;
|
||||
final bool showingBottomSheet;
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
@@ -30,7 +29,6 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
@@ -87,21 +85,13 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
||||
class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const _AssetDetailBottomSheet();
|
||||
|
||||
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
||||
DateTime dateTime = asset.createdAt.toLocal();
|
||||
Duration timeZoneOffset = dateTime.timeZoneOffset;
|
||||
|
||||
// Use EXIF timezone information if available (matching web app behavior)
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
(dateTime, timeZoneOffset) = applyTimezoneOffset(
|
||||
dateTime: exifInfo!.dateTimeOriginal!,
|
||||
timeZone: exifInfo.timeZone,
|
||||
);
|
||||
}
|
||||
|
||||
String _getDateTime(BuildContext ctx, BaseAsset asset) {
|
||||
final dateTime = asset.createdAt.toLocal();
|
||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
|
||||
final timezone = dateTime.timeZoneOffset.isNegative
|
||||
? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'
|
||||
: 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}';
|
||||
return '$date$_kSeparator$time $timezone';
|
||||
}
|
||||
|
||||
@@ -279,7 +269,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
title: _getDateTime(context, asset),
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -143,13 +143,11 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
"enable_backup".t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
Text(
|
||||
"enable_backup".t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
@@ -12,29 +12,13 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
class ServerAuthSettings {
|
||||
final String endpoint;
|
||||
final bool isOAuthEnabled;
|
||||
final bool isPasswordLoginEnabled;
|
||||
final String oAuthButtonText;
|
||||
|
||||
const ServerAuthSettings({
|
||||
required this.endpoint,
|
||||
required this.isOAuthEnabled,
|
||||
required this.isPasswordLoginEnabled,
|
||||
required this.oAuthButtonText,
|
||||
});
|
||||
}
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
return AuthNotifier(
|
||||
ref.watch(authServiceProvider),
|
||||
@@ -43,7 +27,6 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
ref.watch(uploadServiceProvider),
|
||||
ref.watch(secureStorageServiceProvider),
|
||||
ref.watch(widgetServiceProvider),
|
||||
ref.watch(serverInfoServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,7 +37,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final UploadService _uploadService;
|
||||
final SecureStorageService _secureStorageService;
|
||||
final WidgetService _widgetService;
|
||||
final ServerInfoService _serverInfoService;
|
||||
final _log = Logger("AuthenticationNotifier");
|
||||
|
||||
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||
@@ -66,7 +48,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
this._uploadService,
|
||||
this._secureStorageService,
|
||||
this._widgetService,
|
||||
this._serverInfoService,
|
||||
) : super(
|
||||
const AuthState(
|
||||
deviceId: "",
|
||||
@@ -83,27 +64,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
return _authService.validateServerUrl(url);
|
||||
}
|
||||
|
||||
Future<ServerAuthSettings?> getServerAuthSettings(String serverUrl) async {
|
||||
final sanitizedUrl = sanitizeUrl(serverUrl);
|
||||
final encodedUrl = punycodeEncodeUrl(sanitizedUrl);
|
||||
|
||||
final endpoint = await _authService.validateServerUrl(encodedUrl);
|
||||
|
||||
final features = await _serverInfoService.getServerFeatures();
|
||||
final config = await _serverInfoService.getServerConfig();
|
||||
|
||||
if (features == null || config == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ServerAuthSettings(
|
||||
endpoint: endpoint,
|
||||
isOAuthEnabled: features.oauthEnabled,
|
||||
isPasswordLoginEnabled: features.passwordLogin,
|
||||
oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth',
|
||||
);
|
||||
}
|
||||
|
||||
/// Validating the url is the alternative connecting server url without
|
||||
/// saving the information to the local database
|
||||
Future<bool> validateAuxilaryServerUrl(String url) async {
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
@@ -71,7 +70,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
|
||||
Future<List<RemoteAlbum>> sortAlbums(
|
||||
List<RemoteAlbum> albums,
|
||||
AlbumSortMode sortMode, {
|
||||
RemoteAlbumSortMode sortMode, {
|
||||
bool isReverse = false,
|
||||
}) async {
|
||||
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/oauth.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
export 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
|
||||
final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));
|
||||
|
||||
final oAuthProvider = StateNotifierProvider<OAuthNotifier, AsyncValue<void>>(
|
||||
(ref) => OAuthNotifier(ref.watch(oAuthServiceProvider)),
|
||||
);
|
||||
|
||||
class OAuthNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
final OAuthService _oAuthService;
|
||||
|
||||
OAuthNotifier(this._oAuthService) : super(const AsyncValue.data(null));
|
||||
|
||||
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) {
|
||||
return _oAuthService.getOAuthLoginData(serverUrl);
|
||||
}
|
||||
|
||||
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
|
||||
return _oAuthService.completeOAuthLogin(oAuthData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>(
|
||||
@@ -9,6 +10,11 @@ final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectSta
|
||||
dependencies: [timelineServiceProvider],
|
||||
);
|
||||
|
||||
class MultiSelectToggleEvent extends Event {
|
||||
final bool isEnabled;
|
||||
const MultiSelectToggleEvent(this.isEnabled);
|
||||
}
|
||||
|
||||
class MultiSelectState {
|
||||
final Set<BaseAsset> selectedAssets;
|
||||
final Set<BaseAsset> lockedSelectionAssets;
|
||||
|
||||
@@ -245,15 +245,23 @@ class AppRouter extends RootStackRouter {
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
),
|
||||
AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
|
||||
CustomRoute(page: FolderRoute.page, guards: [_authGuard], transitionsBuilder: TransitionsBuilders.fadeIn),
|
||||
AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
CustomRoute(
|
||||
page: TrashRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
),
|
||||
CustomRoute(
|
||||
page: SharedLinkRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
),
|
||||
AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
CustomRoute(
|
||||
page: ActivitiesRoute.page,
|
||||
|
||||
@@ -15,7 +15,6 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||
@@ -176,17 +175,9 @@ class ActionService {
|
||||
}
|
||||
|
||||
final exifData = await _remoteAssetRepository.getExif(assetId);
|
||||
|
||||
// Use EXIF timezone information if available (matching web app and display behavior)
|
||||
DateTime dt = asset.createdAt.toLocal();
|
||||
offset = dt.timeZoneOffset;
|
||||
|
||||
if (exifData?.dateTimeOriginal != null) {
|
||||
timeZone = exifData!.timeZone;
|
||||
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
|
||||
}
|
||||
|
||||
initialDate = dt;
|
||||
initialDate = asset.createdAt.toLocal();
|
||||
offset = initialDate.timeZoneOffset;
|
||||
timeZone = exifData?.timeZone;
|
||||
}
|
||||
|
||||
final dateTime = await showDateTimePicker(
|
||||
|
||||
@@ -51,10 +51,9 @@ enum AppSettingsEnum<T> {
|
||||
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
||||
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
||||
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
|
||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -17,50 +11,6 @@ class OAuthService {
|
||||
final log = Logger('OAuthService');
|
||||
OAuthService(this._apiService);
|
||||
|
||||
String _generateRandomString(int length) {
|
||||
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
|
||||
final random = Random.secure();
|
||||
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
|
||||
}
|
||||
|
||||
List<int> _randomBytes(int length) {
|
||||
final random = Random.secure();
|
||||
return List<int>.generate(length, (i) => random.nextInt(256));
|
||||
}
|
||||
|
||||
/// Per specification, the code verifier must be 43-128 characters long
|
||||
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
|
||||
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
|
||||
String _randomCodeVerifier() {
|
||||
return base64Url.encode(_randomBytes(42));
|
||||
}
|
||||
|
||||
String _generatePKCECodeChallenge(String codeVerifier) {
|
||||
final bytes = utf8.encode(codeVerifier);
|
||||
final digest = sha256.convert(bytes);
|
||||
return base64Url.encode(digest.bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// Initiates OAuth login flow.
|
||||
/// Returns the OAuth server URL to redirect to, along with PKCE parameters.
|
||||
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) async {
|
||||
final state = _generateRandomString(32);
|
||||
final codeVerifier = _randomCodeVerifier();
|
||||
final codeChallenge = _generatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
final oAuthServerUrl = await getOAuthServerUrl(sanitizeUrl(serverUrl), state, codeChallenge);
|
||||
|
||||
if (oAuthServerUrl == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OAuthLoginData(serverUrl: oAuthServerUrl, state: state, codeVerifier: codeVerifier);
|
||||
}
|
||||
|
||||
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
|
||||
return oAuthLogin(oAuthData.serverUrl, oAuthData.state, oAuthData.codeVerifier);
|
||||
}
|
||||
|
||||
Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
|
||||
// Resolve API server endpoint from user provided serverUrl
|
||||
await _apiService.resolveAndSetEndpoint(serverUrl);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
class AlbumFilter {
|
||||
String? userId;
|
||||
@@ -14,12 +14,12 @@ class AlbumFilter {
|
||||
}
|
||||
|
||||
class AlbumSort {
|
||||
AlbumSortMode mode;
|
||||
RemoteAlbumSortMode mode;
|
||||
bool isReverse;
|
||||
|
||||
AlbumSort({required this.mode, this.isReverse = false});
|
||||
|
||||
AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) {
|
||||
AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) {
|
||||
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:timezone/timezone.dart';
|
||||
|
||||
/// Applies timezone conversion to a DateTime using EXIF timezone information.
|
||||
///
|
||||
/// This function handles two timezone formats:
|
||||
/// 1. Named timezone locations (e.g., "Asia/Hong_Kong")
|
||||
/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00")
|
||||
///
|
||||
/// Returns a tuple of (adjusted DateTime, timezone offset Duration)
|
||||
(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) {
|
||||
DateTime dt = dateTime.toUtc();
|
||||
|
||||
if (timeZone == null) {
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get timezone location from database
|
||||
final location = getLocation(timeZone);
|
||||
dt = TZDateTime.from(dt, location);
|
||||
return (dt, dt.timeZoneOffset);
|
||||
} on LocationNotFoundException {
|
||||
// Handle UTC offset format (e.g., "UTC+08:00")
|
||||
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
|
||||
final m = re.firstMatch(timeZone);
|
||||
if (m != null) {
|
||||
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
|
||||
dt = dt.add(duration);
|
||||
return (dt, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// If timezone is invalid, return UTC
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
@@ -193,7 +193,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.pop();
|
||||
launchUrl(Uri.parse('https://docs.immich.app'), mode: LaunchMode.externalApplication);
|
||||
launchUrl(Uri.parse('https://immich.app'), mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text("documentation", style: context.textTheme.bodySmall).tr(),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -6,8 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class LoginButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
class LoginButton extends ConsumerWidget {
|
||||
final Function() onPressed;
|
||||
|
||||
const LoginButton({super.key, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
|
||||
onPressed: onPressed,
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/version_compatibility_warning.dart';
|
||||
|
||||
class LoginCredentialsForm extends StatelessWidget {
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController passwordController;
|
||||
final TextEditingController serverEndpointController;
|
||||
final FocusNode emailFocusNode;
|
||||
final FocusNode passwordFocusNode;
|
||||
final bool isLoading;
|
||||
final bool isOAuthEnabled;
|
||||
final bool isPasswordLoginEnabled;
|
||||
final String oAuthButtonLabel;
|
||||
final String? warningMessage;
|
||||
final VoidCallback onLogin;
|
||||
final VoidCallback onOAuthLogin;
|
||||
final VoidCallback onBack;
|
||||
|
||||
const LoginCredentialsForm({
|
||||
super.key,
|
||||
required this.emailController,
|
||||
required this.passwordController,
|
||||
required this.serverEndpointController,
|
||||
required this.emailFocusNode,
|
||||
required this.passwordFocusNode,
|
||||
required this.isLoading,
|
||||
required this.isOAuthEnabled,
|
||||
required this.isPasswordLoginEnabled,
|
||||
required this.oAuthButtonLabel,
|
||||
required this.warningMessage,
|
||||
required this.onLogin,
|
||||
required this.onOAuthLogin,
|
||||
required this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AutofillGroup(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (warningMessage != null) VersionCompatibilityWarning(message: warningMessage!),
|
||||
Text(
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
style: context.textTheme.displaySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isPasswordLoginEnabled) ...[
|
||||
const SizedBox(height: 18),
|
||||
EmailInput(
|
||||
controller: emailController,
|
||||
focusNode: emailFocusNode,
|
||||
onSubmit: passwordFocusNode.requestFocus,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: onLogin),
|
||||
],
|
||||
isLoading
|
||||
? const LoadingIcon()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
if (isPasswordLoginEnabled) LoginButton(onPressed: onLogin),
|
||||
if (isOAuthEnabled) ...[
|
||||
if (isPasswordLoginEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
|
||||
),
|
||||
OAuthLoginButton(
|
||||
serverEndpointController: serverEndpointController,
|
||||
buttonLabel: oAuthButtonLabel,
|
||||
onPressed: onOAuthLogin,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (!isOAuthEnabled && !isPasswordLoginEnabled) Center(child: const Text('login_disabled').tr()),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(icon: const Icon(Icons.arrow_back), onPressed: onBack, label: const Text('back').tr()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
@@ -25,382 +29,492 @@ import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/login_credentials_form.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/server_selection_form.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
|
||||
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class LoginForm extends ConsumerStatefulWidget {
|
||||
const LoginForm({super.key});
|
||||
class LoginForm extends HookConsumerWidget {
|
||||
LoginForm({super.key});
|
||||
|
||||
final log = Logger('LoginForm');
|
||||
|
||||
@override
|
||||
ConsumerState<LoginForm> createState() => _LoginFormState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final emailFocusNode = useFocusNode();
|
||||
final passwordFocusNode = useFocusNode();
|
||||
final serverEndpointFocusNode = useFocusNode();
|
||||
final isLoading = useState<bool>(false);
|
||||
final isLoadingServer = useState<bool>(false);
|
||||
final isOauthEnable = useState<bool>(false);
|
||||
final isPasswordLoginEnable = useState<bool>(false);
|
||||
final oAuthButtonLabel = useState<String>('OAuth');
|
||||
final logoAnimationController = useAnimationController(duration: const Duration(seconds: 60))..repeat();
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final warningMessage = useState<String?>(null);
|
||||
final loginFormKey = GlobalKey<FormState>();
|
||||
final ValueNotifier<String?> serverEndpoint = useState<String?>(null);
|
||||
|
||||
class _LoginFormState extends ConsumerState<LoginForm> with SingleTickerProviderStateMixin {
|
||||
final _log = Logger('LoginForm');
|
||||
final _loginFormKey = GlobalKey<FormState>();
|
||||
checkVersionMismatch() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final appVersion = packageInfo.version;
|
||||
final appMajorVersion = int.parse(appVersion.split('.')[0]);
|
||||
final appMinorVersion = int.parse(appVersion.split('.')[1]);
|
||||
final serverMajorVersion = serverInfo.serverVersion.major;
|
||||
final serverMinorVersion = serverInfo.serverVersion.minor;
|
||||
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _passwordController;
|
||||
late final TextEditingController _serverEndpointController;
|
||||
late final FocusNode _emailFocusNode;
|
||||
late final FocusNode _passwordFocusNode;
|
||||
late final FocusNode _serverEndpointFocusNode;
|
||||
late final AnimationController _logoAnimationController;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingServer = false;
|
||||
bool _isOAuthEnabled = false;
|
||||
bool _isPasswordLoginEnabled = false;
|
||||
String _oAuthButtonLabel = 'OAuth';
|
||||
String? _serverEndpoint;
|
||||
String? _warningMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_emailController = TextEditingController();
|
||||
_passwordController = TextEditingController();
|
||||
_serverEndpointController = TextEditingController();
|
||||
_emailFocusNode = FocusNode();
|
||||
_passwordFocusNode = FocusNode();
|
||||
_serverEndpointFocusNode = FocusNode();
|
||||
_logoAnimationController = AnimationController(vsync: this, duration: const Duration(seconds: 60))..repeat();
|
||||
|
||||
// Load saved server URL if available
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final serverUrl = getServerUrl();
|
||||
if (serverUrl != null) {
|
||||
_serverEndpointController.text = serverUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_serverEndpointController.dispose();
|
||||
_emailFocusNode.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
_serverEndpointFocusNode.dispose();
|
||||
_logoAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkVersionMismatch() async {
|
||||
try {
|
||||
final serverInfo = ref.read(serverInfoProvider);
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final appVersion = packageInfo.version;
|
||||
final appMajorVersion = int.parse(appVersion.split('.')[0]);
|
||||
final appMinorVersion = int.parse(appVersion.split('.')[1]);
|
||||
final serverMajorVersion = serverInfo.serverVersion.major;
|
||||
final serverMinorVersion = serverInfo.serverVersion.minor;
|
||||
|
||||
setState(() {
|
||||
_warningMessage = getVersionCompatibilityMessage(
|
||||
warningMessage.value = getVersionCompatibilityMessage(
|
||||
appMajorVersion,
|
||||
appMinorVersion,
|
||||
serverMajorVersion,
|
||||
serverMinorVersion,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
_warningMessage = 'Error checking version compatibility';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getServerAuthSettings() async {
|
||||
final serverUrl = _serverEndpointController.text;
|
||||
|
||||
if (serverUrl.isEmpty) {
|
||||
ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error);
|
||||
return;
|
||||
} catch (error) {
|
||||
warningMessage.value = 'Error checking version compatibility';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setState(() {
|
||||
_isLoadingServer = true;
|
||||
});
|
||||
/// Fetch the server login credential and enables oAuth login if necessary
|
||||
/// Returns true if successful, false otherwise
|
||||
Future<void> getServerAuthSettings() async {
|
||||
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
|
||||
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
|
||||
|
||||
final settings = await ref.read(authProvider.notifier).getServerAuthSettings(serverUrl);
|
||||
if (settings == null) {
|
||||
// Guard empty URL
|
||||
if (serverUrl.isEmpty) {
|
||||
ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error);
|
||||
}
|
||||
|
||||
try {
|
||||
isLoadingServer.value = true;
|
||||
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
|
||||
|
||||
// Fetch and load server config and features
|
||||
await ref.read(serverInfoProvider.notifier).getServerInfo();
|
||||
|
||||
final serverInfo = ref.read(serverInfoProvider);
|
||||
final features = serverInfo.serverFeatures;
|
||||
final config = serverInfo.serverConfig;
|
||||
|
||||
isOauthEnable.value = features.oauthEnabled;
|
||||
isPasswordLoginEnable.value = features.passwordLogin;
|
||||
oAuthButtonLabel.value = config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth';
|
||||
|
||||
serverEndpoint.value = endpoint;
|
||||
} on ApiException catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: e.message ?? 'login_form_api_exception'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
isOauthEnable.value = false;
|
||||
isPasswordLoginEnable.value = true;
|
||||
isLoadingServer.value = false;
|
||||
} on HandshakeException {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'login_form_handshake_exception'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
isOauthEnable.value = false;
|
||||
isPasswordLoginEnable.value = true;
|
||||
isLoadingServer.value = false;
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'login_form_server_error'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
_resetServerState();
|
||||
isOauthEnable.value = false;
|
||||
isPasswordLoginEnable.value = true;
|
||||
isLoadingServer.value = false;
|
||||
}
|
||||
|
||||
isLoadingServer.value = false;
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final serverUrl = getServerUrl();
|
||||
if (serverUrl != null) {
|
||||
serverEndpointController.text = serverUrl;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
populateTestLoginInfo() {
|
||||
emailController.text = 'demo@immich.app';
|
||||
passwordController.text = 'demo';
|
||||
serverEndpointController.text = 'https://demo.immich.app';
|
||||
}
|
||||
|
||||
populateTestLoginInfo1() {
|
||||
emailController.text = 'testuser@email.com';
|
||||
passwordController.text = 'password';
|
||||
serverEndpointController.text = 'http://10.1.15.216:2283/api';
|
||||
}
|
||||
|
||||
Future<void> handleSyncFlow() async {
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
|
||||
await backgroundManager.syncLocal(full: true);
|
||||
await backgroundManager.syncRemote();
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
}
|
||||
}
|
||||
|
||||
getManageMediaPermission() async {
|
||||
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'manage_media_access_title',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
|
||||
).tr(),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
|
||||
const SizedBox(height: 4),
|
||||
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'cancel'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'manage_media_access_settings'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
|
||||
|
||||
login() async {
|
||||
TextInput.finishAutofillContext();
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
// Invalidate all api repository provider instance to take into account new access token
|
||||
invalidateAllApiRepositoryProviders(ref);
|
||||
|
||||
try {
|
||||
final result = await ref.read(authProvider.notifier).login(emailController.text, passwordController.text);
|
||||
|
||||
if (result.shouldChangePassword && !result.isAdmin) {
|
||||
unawaited(context.pushRoute(const ChangePasswordRoute()));
|
||||
} else {
|
||||
final isBeta = Store.isBetaTimelineEnabled;
|
||||
if (isBeta) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
if (isSyncRemoteDeletionsMode()) {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
return;
|
||||
}
|
||||
unawaited(context.replaceRoute(const TabControllerRoute()));
|
||||
}
|
||||
} catch (error) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_failed_login".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
String generateRandomString(int length) {
|
||||
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
|
||||
final random = Random.secure();
|
||||
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
|
||||
}
|
||||
|
||||
List<int> randomBytes(int length) {
|
||||
final random = Random.secure();
|
||||
return List<int>.generate(length, (i) => random.nextInt(256));
|
||||
}
|
||||
|
||||
/// Per specification, the code verifier must be 43-128 characters long
|
||||
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
|
||||
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
|
||||
String randomCodeVerifier() {
|
||||
return base64Url.encode(randomBytes(42));
|
||||
}
|
||||
|
||||
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
|
||||
var bytes = utf8.encode(codeVerifier);
|
||||
var digest = sha256.convert(bytes);
|
||||
return base64Url.encode(digest.bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
oAuthLogin() async {
|
||||
var oAuthService = ref.watch(oAuthServiceProvider);
|
||||
String? oAuthServerUrl;
|
||||
|
||||
final state = generateRandomString(32);
|
||||
|
||||
final codeVerifier = randomCodeVerifier();
|
||||
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
try {
|
||||
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
state,
|
||||
codeChallenge,
|
||||
);
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
// Invalidate all api repository provider instance to take into account new access token
|
||||
invalidateAllApiRepositoryProviders(ref);
|
||||
} catch (error, stack) {
|
||||
log.severe('Error getting OAuth server Url: $error', stack);
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_failed_get_oauth_server_config".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isOAuthEnabled = settings.isOAuthEnabled;
|
||||
_isPasswordLoginEnabled = settings.isPasswordLoginEnabled;
|
||||
_oAuthButtonLabel = settings.oAuthButtonText;
|
||||
_serverEndpoint = settings.endpoint;
|
||||
_isLoadingServer = false;
|
||||
});
|
||||
if (oAuthServerUrl != null) {
|
||||
try {
|
||||
final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier);
|
||||
|
||||
await _checkVersionMismatch();
|
||||
} on ApiException catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: e.message ?? 'login_form_api_exception'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
_resetServerState();
|
||||
} on HandshakeException {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'login_form_handshake_exception'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
_resetServerState();
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'login_form_server_error'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
_resetServerState();
|
||||
}
|
||||
}
|
||||
|
||||
void _resetServerState() {
|
||||
setState(() {
|
||||
_isOAuthEnabled = false;
|
||||
_isPasswordLoginEnabled = true;
|
||||
_isLoadingServer = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _populateTestLoginInfo() {
|
||||
_emailController.text = 'demo@immich.app';
|
||||
_passwordController.text = 'demo';
|
||||
_serverEndpointController.text = 'https://demo.immich.app';
|
||||
}
|
||||
|
||||
void _populateTestLoginInfo1() {
|
||||
_emailController.text = 'testuser@email.com';
|
||||
_passwordController.text = 'password';
|
||||
_serverEndpointController.text = 'http://10.1.15.216:2283/api';
|
||||
}
|
||||
|
||||
Future<void> _handleSyncFlow() async {
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
|
||||
await backgroundManager.syncLocal(full: true);
|
||||
await backgroundManager.syncRemote();
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getManageMediaPermission() async {
|
||||
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'manage_media_access_title',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
|
||||
).tr(),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
|
||||
const SizedBox(height: 4),
|
||||
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'cancel'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'manage_media_access_settings'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
|
||||
|
||||
Future<void> _login() async {
|
||||
TextInput.finishAutofillContext();
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Invalidate all api repository provider instance to take into account new access token
|
||||
invalidateAllApiRepositoryProviders(ref);
|
||||
|
||||
try {
|
||||
final result = await ref.read(authProvider.notifier).login(_emailController.text, _passwordController.text);
|
||||
|
||||
if (result.shouldChangePassword && !result.isAdmin) {
|
||||
unawaited(context.pushRoute(const ChangePasswordRoute()));
|
||||
} else {
|
||||
final isBeta = Store.isBetaTimelineEnabled;
|
||||
if (isBeta) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
if (_isSyncRemoteDeletionsMode()) {
|
||||
await _getManageMediaPermission();
|
||||
if (loginResponseDto == null) {
|
||||
return;
|
||||
}
|
||||
unawaited(_handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
return;
|
||||
|
||||
log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
|
||||
|
||||
final isSuccess = await ref
|
||||
.watch(authProvider.notifier)
|
||||
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
|
||||
|
||||
if (isSuccess) {
|
||||
isLoading.value = false;
|
||||
final permission = ref.watch(galleryPermissionNotifier);
|
||||
final isBeta = Store.isBetaTimelineEnabled;
|
||||
if (!isBeta && (permission.isGranted || permission.isLimited)) {
|
||||
unawaited(ref.watch(backupProvider.notifier).resumeBackup());
|
||||
}
|
||||
if (isBeta) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
if (isSyncRemoteDeletionsMode()) {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
return;
|
||||
}
|
||||
unawaited(context.replaceRoute(const TabControllerRoute()));
|
||||
}
|
||||
} catch (error, stack) {
|
||||
log.severe('Error logging in with OAuth: $error', stack);
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: error.toString(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
unawaited(context.replaceRoute(const TabControllerRoute()));
|
||||
}
|
||||
} catch (error) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_failed_login".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _oAuthLogin() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Invalidate all api repository provider instance to take into account new access token
|
||||
invalidateAllApiRepositoryProviders(ref);
|
||||
|
||||
try {
|
||||
final oAuthData = await ref
|
||||
.read(oAuthProvider.notifier)
|
||||
.getOAuthLoginData(sanitizeUrl(_serverEndpointController.text));
|
||||
|
||||
if (oAuthData == null) {
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_failed_get_oauth_server_disable".tr(),
|
||||
toastType: ToastType.info,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final loginResponseDto = await ref.read(oAuthProvider.notifier).completeOAuthLogin(oAuthData);
|
||||
|
||||
if (loginResponseDto == null) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
|
||||
|
||||
final isSuccess = await ref.read(authProvider.notifier).saveAuthInfo(accessToken: loginResponseDto.accessToken);
|
||||
|
||||
if (isSuccess) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
final permission = ref.read(galleryPermissionNotifier);
|
||||
final isBeta = Store.isBetaTimelineEnabled;
|
||||
if (!isBeta && (permission.isGranted || permission.isLimited)) {
|
||||
unawaited(ref.read(backupProvider.notifier).resumeBackup());
|
||||
}
|
||||
if (isBeta) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
if (_isSyncRemoteDeletionsMode()) {
|
||||
await _getManageMediaPermission();
|
||||
}
|
||||
unawaited(_handleSyncFlow());
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
return;
|
||||
}
|
||||
unawaited(context.replaceRoute(const TabControllerRoute()));
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe('Error logging in with OAuth: $error', stack);
|
||||
|
||||
ImmichToast.show(context: context, msg: error.toString(), toastType: ToastType.error, gravity: ToastGravity.TOP);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _goBack() {
|
||||
setState(() {
|
||||
_serverEndpoint = null;
|
||||
});
|
||||
}
|
||||
buildSelectServer() {
|
||||
const buttonRadius = 25.0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ServerEndpointInput(
|
||||
controller: serverEndpointController,
|
||||
focusNode: serverEndpointFocusNode,
|
||||
onSubmit: getServerAuthSettings,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(buttonRadius),
|
||||
bottomLeft: Radius.circular(buttonRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () => context.pushRoute(const SettingsRoute()),
|
||||
icon: const Icon(Icons.settings_rounded),
|
||||
label: const Text(""),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 1),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(buttonRadius),
|
||||
bottomRight: Radius.circular(buttonRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: isLoadingServer.value ? null : getServerAuthSettings,
|
||||
icon: const Icon(Icons.arrow_forward_rounded),
|
||||
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (isLoadingServer.value) const LoadingIcon(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final serverSelectionOrLogin = _serverEndpoint == null
|
||||
? ServerSelectionForm(
|
||||
serverEndpointController: _serverEndpointController,
|
||||
serverEndpointFocusNode: _serverEndpointFocusNode,
|
||||
isLoading: _isLoadingServer,
|
||||
onSubmit: _getServerAuthSettings,
|
||||
)
|
||||
: LoginCredentialsForm(
|
||||
emailController: _emailController,
|
||||
passwordController: _passwordController,
|
||||
serverEndpointController: _serverEndpointController,
|
||||
emailFocusNode: _emailFocusNode,
|
||||
passwordFocusNode: _passwordFocusNode,
|
||||
isLoading: _isLoading,
|
||||
isOAuthEnabled: _isOAuthEnabled,
|
||||
isPasswordLoginEnabled: _isPasswordLoginEnabled,
|
||||
oAuthButtonLabel: _oAuthButtonLabel,
|
||||
warningMessage: _warningMessage,
|
||||
onLogin: _login,
|
||||
onOAuthLogin: _oAuthLogin,
|
||||
onBack: _goBack,
|
||||
);
|
||||
buildVersionCompatWarning() {
|
||||
checkVersionMismatch();
|
||||
|
||||
if (warningMessage.value == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
|
||||
),
|
||||
child: Text(warningMessage.value!, textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildLogin() {
|
||||
return AutofillGroup(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
buildVersionCompatWarning(),
|
||||
Text(
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
style: context.textTheme.displaySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isPasswordLoginEnable.value) ...[
|
||||
const SizedBox(height: 18),
|
||||
EmailInput(
|
||||
controller: emailController,
|
||||
focusNode: emailFocusNode,
|
||||
onSubmit: passwordFocusNode.requestFocus,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login),
|
||||
],
|
||||
|
||||
// Note: This used to have an AnimatedSwitcher, but was removed
|
||||
// because of https://github.com/flutter/flutter/issues/120874
|
||||
isLoading.value
|
||||
? const LoadingIcon()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
if (isPasswordLoginEnable.value) LoginButton(onPressed: login),
|
||||
if (isOauthEnable.value) ...[
|
||||
if (isPasswordLoginEnable.value)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
|
||||
),
|
||||
OAuthLoginButton(
|
||||
serverEndpointController: serverEndpointController,
|
||||
buttonLabel: oAuthButtonLabel.value,
|
||||
isLoading: isLoading,
|
||||
onPressed: oAuthLogin,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => serverEndpoint.value = null,
|
||||
label: const Text('back').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin();
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -418,19 +532,20 @@ class _LoginFormState extends ConsumerState<LoginForm> with SingleTickerProvider
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onDoubleTap: _populateTestLoginInfo,
|
||||
onLongPress: _populateTestLoginInfo1,
|
||||
onDoubleTap: () => populateTestLoginInfo(),
|
||||
onLongPress: () => populateTestLoginInfo1(),
|
||||
child: RotationTransition(
|
||||
turns: _logoAnimationController,
|
||||
turns: logoAnimationController,
|
||||
child: const ImmichLogo(heroTag: 'logo'),
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.only(top: 8.0, bottom: 16), child: ImmichTitleText()),
|
||||
],
|
||||
),
|
||||
|
||||
// Note: This used to have an AnimatedSwitcher, but was removed
|
||||
// because of https://github.com/flutter/flutter/issues/120874
|
||||
Form(key: _loginFormKey, child: serverSelectionOrLogin),
|
||||
Form(key: loginFormKey, child: serverSelectionOrLogin),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class OAuthLoginButton extends StatelessWidget {
|
||||
class OAuthLoginButton extends ConsumerWidget {
|
||||
final TextEditingController serverEndpointController;
|
||||
final ValueNotifier<bool> isLoading;
|
||||
final String buttonLabel;
|
||||
final VoidCallback onPressed;
|
||||
final Function() onPressed;
|
||||
|
||||
const OAuthLoginButton({
|
||||
super.key,
|
||||
required this.serverEndpointController,
|
||||
required this.isLoading,
|
||||
required this.buttonLabel,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: context.primaryColor.withAlpha(230),
|
||||
|
||||
@@ -1,45 +1,36 @@
|
||||
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';
|
||||
|
||||
class PasswordInput extends StatefulWidget {
|
||||
class PasswordInput extends HookConsumerWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final VoidCallback? onSubmit;
|
||||
final Function()? onSubmit;
|
||||
|
||||
const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit});
|
||||
|
||||
@override
|
||||
State<PasswordInput> createState() => _PasswordInputState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPasswordVisible = useState<bool>(false);
|
||||
|
||||
class _PasswordInputState extends State<PasswordInput> {
|
||||
bool _isPasswordVisible = false;
|
||||
|
||||
void _togglePasswordVisibility() {
|
||||
setState(() {
|
||||
_isPasswordVisible = !_isPasswordVisible;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
obscureText: !_isPasswordVisible,
|
||||
controller: widget.controller,
|
||||
obscureText: !isPasswordVisible.value,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'password'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_password_hint'.tr(),
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: _togglePasswordVisibility,
|
||||
icon: Icon(_isPasswordVisible ? Icons.visibility_off_sharp : Icons.visibility_sharp),
|
||||
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
|
||||
icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp),
|
||||
),
|
||||
),
|
||||
autofillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.text,
|
||||
onFieldSubmitted: (_) => widget.onSubmit?.call(),
|
||||
focusNode: widget.focusNode,
|
||||
onFieldSubmitted: (_) => onSubmit?.call(),
|
||||
focusNode: focusNode,
|
||||
textInputAction: TextInputAction.go,
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user