Merge remote-tracking branch 'upstream/main' into feature/kebab-menu-2

This commit is contained in:
idubnori
2025-12-04 13:39:00 +09:00
314 changed files with 20092 additions and 6882 deletions
+1 -1
View File
@@ -1 +1 @@
24.11.0 24.11.1
+1 -1
View File
@@ -105,7 +105,7 @@ jobs:
- name: Generate docker image tags - name: Generate docker image tags
id: metadata id: metadata
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with: with:
flavor: | flavor: |
latest=false latest=false
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run] needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }} if: ${{ needs.should_run.outputs.should_run == 'true' }}
container: container:
image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271 image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
outputs: outputs:
checked: ${{ steps.get_checkbox.outputs.checked }} checked: ${{ steps.get_checkbox.outputs.checked }}
steps: steps:
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
# ️ Command-line programs to run using the OS shell. # ️ 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 # 📚 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 # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm' suffixes: '-rocm'
platforms: linux/amd64 platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}' runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1 uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions: permissions:
contents: read contents: read
actions: read actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server name: Build and Push Server
needs: pre-job needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }} if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1 uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions: permissions:
contents: read contents: read
actions: read actions: read
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token - name: Generate a token
id: generate_token id: generate_token
if: ${{ inputs.skip != true }} if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+3 -3
View File
@@ -49,7 +49,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -62,7 +62,7 @@ jobs:
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3 uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -126,7 +126,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,7 +30,7 @@ jobs:
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3 uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -159,7 +159,7 @@ jobs:
- name: Create PR - name: Create PR
id: create-pr id: create-pr
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -74,7 +74,7 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK - name: Download APK
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: release-apk-signed name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
+2 -2
View File
@@ -571,8 +571,8 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3 uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with: # with:
# python-version: 3.11 # python-version: 3.11
+1 -1
View File
@@ -52,7 +52,7 @@
}, },
"cSpell.words": ["immich"], "cSpell.words": ["immich"],
"editor.formatOnSave": true, "editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"], "eslint.validate": ["javascript", "typescript", "svelte"],
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart", "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
+1 -1
View File
@@ -1 +1 @@
24.11.0 24.11.1
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./ COPY package* pnpm* .pnpmfile.cjs ./
+3 -3
View File
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.19.1", "@types/node": "^24.10.1",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -28,7 +28,7 @@
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8" "micromatch": "^4.0.8"
}, },
"volta": { "volta": {
"node": "24.11.0" "node": "24.11.1"
} }
} }
+1 -1
View File
@@ -299,7 +299,7 @@ describe('crawl', () => {
.map(([file]) => file); .map(([file]) => file);
// Compare file's content instead of path since a file can be represent in multiple ways. // Compare file's content instead of path since a file can be represent in multiple ways.
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort()); expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
}); });
} }
}); });
+1 -1
View File
@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
ignore: [`**/${exclusionPattern}`], ignore: [`**/${exclusionPattern}`],
}); });
globbedFiles.push(...crawledFiles); globbedFiles.push(...crawledFiles);
return globbedFiles.sort(); return globbedFiles.toSorted();
}; };
export const sha1 = (filepath: string) => { export const sha1 = (filepath: string) => {
+1 -1
View File
@@ -9,7 +9,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"target": "es2022", "target": "es2023",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"incremental": true, "incremental": true,
+2 -2
View File
@@ -1,6 +1,6 @@
[tools] [tools]
terragrunt = "0.91.2" terragrunt = "0.93.10"
opentofu = "1.10.6" opentofu = "1.10.7"
[tasks."tg:fmt"] [tasks."tg:fmt"]
run = "terragrunt hclfmt" run = "terragrunt hclfmt"
+1 -1
View File
@@ -135,7 +135,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -95,7 +95,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
+1 -1
View File
@@ -1 +1 @@
24.11.0 24.11.1
+2 -2
View File
@@ -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 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. the job may not have run automatically the first time.
### How can I hide photos from the timeline? ### How can I hide a photo or video from the timeline?
You can _archive_ them. 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
### How can I backup data from Immich? ### How can I backup data from Immich?
@@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
#### Trigger Dump #### Trigger Dump
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status). You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm". Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder. A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
This dumps will count towards the last `X` dumps that will be kept based on your settings. This dumps will count towards the last `X` dumps that will be kept based on your settings.
@@ -21,6 +21,9 @@ server {
# allow large file uploads # allow large file uploads
client_max_body_size 50000M; client_max_body_size 50000M;
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
proxy_request_buffering off;
# Set headers # Set headers
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
+4 -4
View File
@@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
- [ ] `pnpm run check:typescript` (check typescript) - [ ] `pnpm run check:typescript` (check typescript)
- [ ] `pnpm test` (unit tests) - [ ] `pnpm test` (unit tests)
:::tip AIO
Run all web checks with `pnpm run check:all`
:::
## Documentation ## Documentation
- [ ] `pnpm run format` (formatting via Prettier) - [ ] `pnpm run format` (formatting via Prettier)
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation. - [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
:::tip AIO
Run all web checks with `pnpm run check:all`
:::
## Server Checks ## Server Checks
- [ ] `pnpm run lint` (linting via ESLint) - [ ] `pnpm run lint` (linting via ESLint)
-1
View File
@@ -48,7 +48,6 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
**Notes:** **Notes:**
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors - The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
- In case of rootless docker setup, you need to use root within the container, otherwise you will encounter read/write permission related errors, see comments in `docker/docker-compose.dev.yml`.
#### Connect web to a remote backend #### Connect web to a remote backend
+1
View File
@@ -18,6 +18,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_: Before you can run the tests, you need to run the following commands _once_:
- `pnpm install` (in `e2e/`) - `pnpm install` (in `e2e/`)
- `pnpm run build` (in `cli/`)
- `make open-api` (in the project root `/`) - `make open-api` (in the project root `/`)
Once the test environment is running, the e2e tests can be run via: Once the test environment is running, the e2e tests can be run via:
+1 -1
View File
@@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07 [huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7 [huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search [smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
[job-status-page]: https://my.immich.app/admin/jobs-status [job-status-page]: https://my.immich.app/admin/queues
+1 -1
View File
@@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used. Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried. Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried.
## Load balancing ## Load balancing
+6 -6
View File
@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
## Ports ## Ports
| Variable | Description | Default | | Variable | Description | Default | Containers |
| :------------ | :------------- | :----------------------------------------: | | :------------ | :------------- | :----------------------------------------: | :----------------------- |
| `IMMICH_HOST` | Listening host | `0.0.0.0` | | `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | | `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
## Database ## 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_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | 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_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` | server | | `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
\*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`. \*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`.
@@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`. All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`. `DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`. You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored. When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
+1 -1
View File
@@ -57,6 +57,6 @@
"node": ">=20" "node": ">=20"
}, },
"volta": { "volta": {
"node": "24.11.0" "node": "24.11.1"
} }
} }
+1 -1
View File
@@ -1 +1 @@
24.11.0 24.11.1
+5 -5
View File
@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.19.1", "@types/node": "^24.10.1",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -35,8 +35,8 @@
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^31.1.0", "exiftool-vendored": "^33.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jose": "^5.6.3", "jose": "^5.6.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",
@@ -45,7 +45,7 @@
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.4", "sharp": "^0.34.5",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
@@ -54,6 +54,6 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "24.11.0" "node": "24.11.1"
} }
} }
+1 -1
View File
@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
}); });
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => { it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`], importPaths: [`${testAssetDirInternal}/temp/xmp`],
+1 -1
View File
@@ -61,7 +61,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
} }
} }
return [...selectedDays].sort((a, b) => b - a); return [...selectedDays].toSorted((a, b) => b - a);
} }
/** /**
+27 -17
View File
@@ -62,50 +62,60 @@ export const setupTimelineMockApiRoutes = async (
return route.continue(); return route.continue();
}); });
await context.route('**/api/assets/**', async (route, request) => { 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) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/; const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern); const match = request.url().match(pattern);
if (!match) { if (!match?.groups) {
const url = new URL(request.url()); throw new Error(`Invalid URL for thumbnail endpoint: ${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()) { if (!route.request().serviceWorker()) {
return route.continue(); return route.continue();
} }
const asset = getAsset(timelineRestData, match.groups?.assetId); const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' }, headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
body: await randomPreview( body: await randomPreview(
match.groups?.assetId, match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1), (asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
), ),
}); });
} }
if (match.groups?.size === 'thumbnail') { if (match.groups.size === 'thumbnail') {
if (!route.request().serviceWorker()) { if (!route.request().serviceWorker()) {
return route.continue(); return route.continue();
} }
const asset = getAsset(timelineRestData, match.groups?.assetId); const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
headers: { 'content-type': 'image/jpeg' }, headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail( body: await randomThumbnail(
match.groups?.assetId, match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1), (asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
), ),
}); });
} }
return route.continue(); return route.continue();
}); });
await context.route('**/api/albums/**', async (route, request) => { await context.route('**/api/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/; const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
const match = request.url().match(pattern); const match = request.url().match(pattern);
+3 -3
View File
@@ -12,7 +12,7 @@ import {
PersonCreateDto, PersonCreateDto,
QueueCommandDto, QueueCommandDto,
QueueName, QueueName,
QueuesResponseDto, QueuesResponseLegacyDto,
SharedLinkCreateDto, SharedLinkCreateDto,
UpdateLibraryDto, UpdateLibraryDto,
UserAdminCreateDto, UserAdminCreateDto,
@@ -564,13 +564,13 @@ export const utils = {
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
}, },
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => { isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) }); const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
const jobCounts = queues[queue].jobCounts; const jobCounts = queues[queue].jobCounts;
return !jobCounts.active && !jobCounts.waiting; return !jobCounts.active && !jobCounts.waiting;
}, },
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => { waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
// eslint-disable-next-line no-async-promise-executor // eslint-disable-next-line no-async-promise-executor
return new Promise<void>(async (resolve, reject) => { return new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000); const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
@@ -611,6 +611,53 @@ test.describe('Timeline', () => {
await page.getByText('Photos', { exact: true }).click(); await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id); 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 }) => { test('open album, archive photo, open album, unarchive', async ({ page }) => {
const album = timelineRestData.album; const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id); await pageUtils.openAlbumPage(page, album.id);
@@ -633,8 +680,7 @@ test.describe('Timeline', () => {
visibility: 'archive', visibility: 'archive',
ids: [assetToArchive.id], ids: [assetToArchive.id],
}); });
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon'); await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click(); await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
@@ -656,8 +702,7 @@ test.describe('Timeline', () => {
visibility: 'timeline', visibility: 'timeline',
ids: [assetToArchive.id], ids: [assetToArchive.id],
}); });
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive'); await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id); await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToArchive.id); await thumbnailUtils.expectInViewport(page, assetToArchive.id);
}); });
@@ -712,6 +757,50 @@ test.describe('Timeline', () => {
await page.getByText('Photos', { exact: true }).click(); await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id); 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 }) => { test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
const album = timelineRestData.album; const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id); await pageUtils.openAlbumPage(page, album.id);
+16 -12
View File
@@ -105,20 +105,16 @@ export const thumbnailUtils = {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector)); return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
}, },
async expectThumbnailIsFavorite(page: Page, assetId: string) { async expectThumbnailIsFavorite(page: Page, assetId: string) {
await expect( await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
thumbnailUtils },
.withAssetId(page, assetId) async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
.locator( await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
'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) { async expectThumbnailIsArchive(page: Page, assetId: string) {
await expect( await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
thumbnailUtils },
.withAssetId(page, assetId) async expectThumbnailIsNotArchive(page: Page, assetId: string) {
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'), await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
).toHaveCount(1);
}, },
async expectSelectedReadonly(page: Page, assetId: string) { async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected // todo - need a data attribute for selected
@@ -208,10 +204,18 @@ export const pageUtils = {
await page.goto(`/photos`); await page.goto(`/photos`);
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
}, },
async openFavorites(page: Page) {
await page.goto(`/favorites`);
await timelineUtils.waitForTimelineLoad(page);
},
async openAlbumPage(page: Page, albumId: string) { async openAlbumPage(page: Page, albumId: string) {
await page.goto(`/albums/${albumId}`); await page.goto(`/albums/${albumId}`);
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
}, },
async openArchivePage(page: Page) {
await page.goto(`/archive`);
await timelineUtils.waitForTimelineLoad(page);
},
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) { async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
await page.goto(`/albums/${albumId}?at=${assetId}`); await page.goto(`/albums/${albumId}?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
+2 -2
View File
@@ -54,7 +54,7 @@ test.describe('User Administration', () => {
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked(); await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByText('Admin User').click(); await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked(); await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
@@ -83,7 +83,7 @@ test.describe('User Administration', () => {
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked(); await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByText('Admin User').click(); await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked(); await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
+1 -1
View File
@@ -9,7 +9,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"target": "es2022", "target": "es2023",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"incremental": true, "incremental": true,
+21 -1
View File
@@ -7,6 +7,7 @@
"action_common_update": "Update", "action_common_update": "Update",
"actions": "Actions", "actions": "Actions",
"active": "Active", "active": "Active",
"active_count": "Active: {count}",
"activity": "Activity", "activity": "Activity",
"activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}", "activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}",
"add": "Add", "add": "Add",
@@ -67,6 +68,7 @@
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "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_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?", "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", "create_job": "Create job",
"cron_expression": "Cron expression", "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>", "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
@@ -74,6 +76,8 @@
"disable_login": "Disable login", "disable_login": "Disable login",
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search", "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.", "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", "external_library_management": "External Library Management",
"face_detection": "Face detection", "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.", "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.",
@@ -102,14 +106,15 @@
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline", "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_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
"image_thumbnail_title": "Thumbnail Settings", "image_thumbnail_title": "Thumbnail Settings",
"import_config_from_json_description": "Import system config by uploading a JSON config file",
"job_concurrency": "{job} concurrency", "job_concurrency": "{job} concurrency",
"job_created": "Job created", "job_created": "Job created",
"job_not_concurrency_safe": "This job is not concurrency-safe.", "job_not_concurrency_safe": "This job is not concurrency-safe.",
"job_settings": "Job Settings", "job_settings": "Job Settings",
"job_settings_description": "Manage job concurrency", "job_settings_description": "Manage job concurrency",
"job_status": "Job Status",
"jobs_delayed": "{jobCount, plural, other {# delayed}}", "jobs_delayed": "{jobCount, plural, other {# delayed}}",
"jobs_failed": "{jobCount, plural, other {# failed}}", "jobs_failed": "{jobCount, plural, other {# failed}}",
"jobs_over_time": "Jobs over time",
"library_created": "Created library: {library}", "library_created": "Created library: {library}",
"library_deleted": "Library deleted", "library_deleted": "Library deleted",
"library_details": "Library details", "library_details": "Library details",
@@ -182,6 +187,7 @@
"maintenance_start": "Start maintenance mode", "maintenance_start": "Start maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.", "maintenance_start_error": "Failed to start maintenance mode.",
"manage_concurrency": "Manage Concurrency", "manage_concurrency": "Manage Concurrency",
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
"manage_log_settings": "Manage log settings", "manage_log_settings": "Manage log settings",
"map_dark_style": "Dark style", "map_dark_style": "Dark style",
"map_enable_description": "Enable map features", "map_enable_description": "Enable map features",
@@ -271,10 +277,14 @@
"password_settings_description": "Manage password login settings", "password_settings_description": "Manage password login settings",
"paths_validated_successfully": "All paths validated successfully", "paths_validated_successfully": "All paths validated successfully",
"person_cleanup_job": "Person cleanup", "person_cleanup_job": "Person cleanup",
"queue_details": "Queue Details",
"queues": "Job Queues",
"queues_page_description": "Admin job queues page",
"quota_size_gib": "Quota Size (GiB)", "quota_size_gib": "Quota Size (GiB)",
"refreshing_all_libraries": "Refreshing all libraries", "refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration", "registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.", "registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login", "require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default", "reset_settings_to_default": "Reset settings to default",
"reset_settings_to_recent_saved": "Reset settings to the recent saved settings", "reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
@@ -287,8 +297,10 @@
"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_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": "Server Settings",
"server_settings_description": "Manage server settings", "server_settings_description": "Manage server settings",
"server_stats_page_description": "Admin server statistics page",
"server_welcome_message": "Welcome message", "server_welcome_message": "Welcome message",
"server_welcome_message_description": "A message that is displayed on the login page.", "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": "Sidecar metadata",
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem", "sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
"slideshow_duration_description": "Number of seconds to display each image", "slideshow_duration_description": "Number of seconds to display each image",
@@ -407,6 +419,8 @@
"user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}", "user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}",
"user_settings": "User Settings", "user_settings": "User Settings",
"user_settings_description": "Manage 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_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with github.com", "version_check_implications": "The version check feature relies on periodic communication with github.com",
"version_check_settings": "Version Check", "version_check_settings": "Version Check",
@@ -727,6 +741,7 @@
"collapse_all": "Collapse all", "collapse_all": "Collapse all",
"color": "Color", "color": "Color",
"color_theme": "Color theme", "color_theme": "Color theme",
"command": "Command",
"comment_deleted": "Comment deleted", "comment_deleted": "Comment deleted",
"comment_options": "Comment options", "comment_options": "Comment options",
"comments_and_likes": "Comments & likes", "comments_and_likes": "Comments & likes",
@@ -1091,6 +1106,7 @@
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"face_unassigned": "Unassigned", "face_unassigned": "Unassigned",
"failed": "Failed", "failed": "Failed",
"failed_count": "Failed: {count}",
"failed_to_authenticate": "Failed to authenticate", "failed_to_authenticate": "Failed to authenticate",
"failed_to_load_assets": "Failed to load assets", "failed_to_load_assets": "Failed to load assets",
"failed_to_load_folder": "Failed to load folder", "failed_to_load_folder": "Failed to load folder",
@@ -1512,6 +1528,7 @@
"other_variables": "Other variables", "other_variables": "Other variables",
"owned": "Owned", "owned": "Owned",
"owner": "Owner", "owner": "Owner",
"page": "Page",
"partner": "Partner", "partner": "Partner",
"partner_can_access": "{partner} can access", "partner_can_access": "{partner} can access",
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted", "partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
@@ -2072,6 +2089,7 @@
"to_select": "to select", "to_select": "to select",
"to_trash": "Trash", "to_trash": "Trash",
"toggle_settings": "Toggle settings", "toggle_settings": "Toggle settings",
"toggle_theme_description": "Toggle theme",
"total": "Total", "total": "Total",
"total_usage": "Total usage", "total_usage": "Total usage",
"trash": "Trash", "trash": "Trash",
@@ -2180,6 +2198,7 @@
"view_album": "View Album", "view_album": "View Album",
"view_all": "View All", "view_all": "View All",
"view_all_users": "View all users", "view_all_users": "View all users",
"view_asset_owners": "View asset owners",
"view_details": "View Details", "view_details": "View Details",
"view_in_timeline": "View in timeline", "view_in_timeline": "View in timeline",
"view_link": "View link", "view_link": "View link",
@@ -2196,6 +2215,7 @@
"viewer_unstack": "Un-Stack", "viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"waiting": "Waiting", "waiting": "Waiting",
"waiting_count": "Waiting: {count}",
"warning": "Warning", "warning": "Warning",
"week": "Week", "week": "Week",
"welcome": "Welcome", "welcome": "Welcome",
+6 -6
View File
@@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
FROM builder-cpu AS builder-openvino 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 # Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size # TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime # renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.20.1" ARG ONNXRUNTIME_VERSION="v1.22.1"
WORKDIR /code WORKDIR /code
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv 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; \ uv pip install /opt/onnxruntime_rocm-*.whl; \
fi fi
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \ ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ 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/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so 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:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
FROM prod-cpu AS prod-armnn FROM prod-cpu AS prod-armnn
@@ -82,6 +82,7 @@ class TextDetector(InferenceModel):
ratio = float(self.max_resolution) / img.height ratio = float(self.max_resolution) / img.height
else: else:
ratio = float(self.max_resolution) / img.width ratio = float(self.max_resolution) / img.width
ratio = min(ratio, 1.0)
resize_h = int(img.height * ratio) resize_h = int(img.height * ratio)
resize_w = int(img.width * ratio) resize_w = int(img.width * ratio)
@@ -1,13 +1,13 @@
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index d90a2a355..bb1a7de12 100644 index 2714e6f59..a69da76b4 100644
--- a/cmake/CMakeLists.txt --- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt
@@ -295,7 +295,7 @@ if (onnxruntime_USE_ROCM) @@ -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()
endif() 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/*)
+1851 -1747
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -1,11 +1,12 @@
experimental_monorepo_root = true experimental_monorepo_root = true
[tools] [tools]
node = "24.11.0" node = "24.11.1"
flutter = "3.35.7" flutter = "3.35.7"
pnpm = "10.20.0" pnpm = "10.24.0"
terragrunt = "0.91.2" terragrunt = "0.93.10"
opentofu = "1.10.6" opentofu = "1.10.7"
java = "25.0.1"
[tools."github:CQLabs/homebrew-dcm"] [tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0" version = "1.30.0"
@@ -89,7 +89,10 @@ data class PlatformAsset (
val height: Long? = null, val height: Long? = null,
val durationInSeconds: Long, val durationInSeconds: Long,
val orientation: Long, val orientation: Long,
val isFavorite: Boolean val isFavorite: Boolean,
val adjustmentTime: Long? = null,
val latitude: Double? = null,
val longitude: Double? = null
) )
{ {
companion object { companion object {
@@ -104,7 +107,10 @@ data class PlatformAsset (
val durationInSeconds = pigeonVar_list[7] as Long val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long val orientation = pigeonVar_list[8] as Long
val isFavorite = pigeonVar_list[9] as Boolean val isFavorite = pigeonVar_list[9] as Boolean
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite) val adjustmentTime = pigeonVar_list[10] as Long?
val latitude = pigeonVar_list[11] as Double?
val longitude = pigeonVar_list[12] as Double?
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
} }
} }
fun toList(): List<Any?> { fun toList(): List<Any?> {
@@ -119,6 +125,9 @@ data class PlatformAsset (
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
adjustmentTime,
latitude,
longitude,
) )
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Base64 import android.util.Base64
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -140,6 +140,9 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64 var durationInSeconds: Int64
var orientation: Int64 var orientation: Int64
var isFavorite: Bool var isFavorite: Bool
var adjustmentTime: Int64? = nil
var latitude: Double? = nil
var longitude: Double? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase // swift-format-ignore: AlwaysUseLowerCamelCase
@@ -154,6 +157,9 @@ struct PlatformAsset: Hashable {
let durationInSeconds = pigeonVar_list[7] as! Int64 let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64 let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool let isFavorite = pigeonVar_list[9] as! Bool
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12])
return PlatformAsset( return PlatformAsset(
id: id, id: id,
@@ -165,7 +171,10 @@ struct PlatformAsset: Hashable {
height: height, height: height,
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
orientation: orientation, orientation: orientation,
isFavorite: isFavorite isFavorite: isFavorite,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude
) )
} }
func toList() -> [Any?] { func toList() -> [Any?] {
@@ -180,6 +189,9 @@ struct PlatformAsset: Hashable {
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
adjustmentTime,
latitude,
longitude,
] ]
} }
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
+11 -1
View File
@@ -12,7 +12,10 @@ extension PHAsset {
height: Int64(pixelHeight), height: Int64(pixelHeight),
durationInSeconds: Int64(duration), durationInSeconds: Int64(duration),
orientation: 0, orientation: 0,
isFavorite: isFavorite isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude
) )
} }
@@ -23,6 +26,13 @@ extension PHAsset {
var filename: String? { var filename: String? {
return value(forKey: "filename") as? String return value(forKey: "filename") as? String
} }
var adjustmentTimestamp: Int64? {
if let date = value(forKey: "adjustmentTimestamp") as? Date {
return Int64(date.timeIntervalSince1970)
}
return nil
}
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename // This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
var originalFilename: String? { var originalFilename: String? {
@@ -5,6 +5,10 @@ class LocalAsset extends BaseAsset {
final String? remoteAssetId; final String? remoteAssetId;
final int orientation; final int orientation;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const LocalAsset({ const LocalAsset({
required this.id, required this.id,
String? remoteId, String? remoteId,
@@ -19,6 +23,9 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false, super.isFavorite = false,
super.livePhotoVideoId, super.livePhotoVideoId,
this.orientation = 0, this.orientation = 0,
this.adjustmentTime,
this.latitude,
this.longitude,
}) : remoteAssetId = remoteId; }) : remoteAssetId = remoteId;
@override @override
@@ -33,6 +40,8 @@ class LocalAsset extends BaseAsset {
@override @override
String get heroTag => '${id}_${remoteId ?? checksum}'; String get heroTag => '${id}_${remoteId ?? checksum}';
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
@override @override
String toString() { String toString() {
return '''LocalAsset { return '''LocalAsset {
@@ -47,6 +56,9 @@ class LocalAsset extends BaseAsset {
remoteId: ${remoteId ?? "<NA>"} remoteId: ${remoteId ?? "<NA>"}
isFavorite: $isFavorite, isFavorite: $isFavorite,
orientation: $orientation, orientation: $orientation,
adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"},
}'''; }''';
} }
@@ -55,11 +67,23 @@ class LocalAsset extends BaseAsset {
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! LocalAsset) return false; if (other is! LocalAsset) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return super == other && id == other.id && orientation == other.orientation; return super == other &&
id == other.id &&
orientation == other.orientation &&
adjustmentTime == other.adjustmentTime &&
latitude == other.latitude &&
longitude == other.longitude;
} }
@override @override
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode; int get hashCode =>
super.hashCode ^
id.hashCode ^
remoteId.hashCode ^
orientation.hashCode ^
adjustmentTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
LocalAsset copyWith({ LocalAsset copyWith({
String? id, String? id,
@@ -74,6 +98,9 @@ class LocalAsset extends BaseAsset {
int? durationInSeconds, int? durationInSeconds,
bool? isFavorite, bool? isFavorite,
int? orientation, int? orientation,
DateTime? adjustmentTime,
double? latitude,
double? longitude,
}) { }) {
return LocalAsset( return LocalAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -88,6 +115,9 @@ class LocalAsset extends BaseAsset {
durationInSeconds: durationInSeconds ?? this.durationInSeconds, durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
); );
} }
} }
@@ -0,0 +1,32 @@
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);
}
+1 -1
View File
@@ -37,7 +37,7 @@ class ExifInfo {
String get fNumber => f == null ? "" : f!.toStringAsFixed(1); String get fNumber => f == null ? "" : f!.toStringAsFixed(1);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(1); String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3);
const ExifInfo({ const ExifInfo({
this.assetId, this.assetId,
@@ -71,6 +71,7 @@ enum StoreKey<T> {
readonlyModeEnabled<bool>._(138), readonlyModeEnabled<bool>._(138),
autoPlayVideo<bool>._(139), autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
// Experimental stuff // Experimental stuff
photoManagerCustomFilter<bool>._(1000), photoManagerCustomFilter<bool>._(1000),
@@ -1,5 +1,3 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
enum GroupAssetsBy { day, month, auto, none } enum GroupAssetsBy { day, month, auto, none }
enum HeaderType { none, month, day, monthAndDay } enum HeaderType { none, month, day, monthAndDay }
@@ -31,17 +29,3 @@ class TimeBucket extends Bucket {
@override @override
int get hashCode => super.hashCode ^ date.hashCode; 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);
}
@@ -75,6 +75,20 @@ class AssetService {
isFlipped = false; isFlipped = false;
} }
if (width == null || height == null) {
if (asset.hasRemote) {
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
final remoteAsset = await _remoteAssetRepository.get(id);
width = remoteAsset?.width?.toDouble();
height = remoteAsset?.height?.toDouble();
} else {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
final localAsset = await _localAssetRepository.get(id);
width = localAsset?.width?.toDouble();
height = localAsset?.height?.toDouble();
}
}
final orientedWidth = isFlipped ? height : width; final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height; final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) { if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
@@ -286,11 +286,23 @@ class LocalSyncService {
} }
bool _assetsEqual(LocalAsset a, LocalAsset b) { bool _assetsEqual(LocalAsset a, LocalAsset b) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) && if (CurrentPlatform.isAndroid) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds;
}
final firstAdjustment = a.adjustmentTime?.millisecondsSinceEpoch ?? 0;
final secondAdjustment = b.adjustmentTime?.millisecondsSinceEpoch ?? 0;
return firstAdjustment == secondAdjustment &&
a.createdAt.isAtSameMomentAs(b.createdAt) && a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width && a.width == b.width &&
a.height == b.height && a.height == b.height &&
a.durationInSeconds == b.durationInSeconds; a.durationInSeconds == b.durationInSeconds &&
a.latitude == b.latitude &&
a.longitude == b.longitude;
} }
bool _albumsEqual(LocalAlbum a, LocalAlbum b) { bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
@@ -363,18 +375,21 @@ extension on Iterable<PlatformAsset> {
} }
} }
extension on PlatformAsset { extension PlatformToLocalAsset on PlatformAsset {
LocalAsset toLocalAsset() => LocalAsset( LocalAsset toLocalAsset() => LocalAsset(
id: id, id: id,
name: name, name: name,
checksum: null, checksum: null,
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other, type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(), createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(), updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
width: width, width: width,
height: height, height: height,
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
); );
} }
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.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/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.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 { class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository; final DriftRemoteAlbumRepository _repository;
@@ -32,16 +33,16 @@ class RemoteAlbumService {
Future<List<RemoteAlbum>> sortAlbums( Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { AlbumSortMode sortMode, {
bool isReverse = false, bool isReverse = false,
}) async { }) async {
final List<RemoteAlbum> sorted = switch (sortMode) { final List<RemoteAlbum> sorted = switch (sortMode) {
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt), AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name), AlbumSortMode.title => albums.sortedBy((album) => album.name),
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums), AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums), AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
}; };
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
@@ -211,16 +212,3 @@ class RemoteAlbumService {
return sorted.reversed.toList(); 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,6 +4,7 @@ import 'dart:math' as math;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/constants.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/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/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
+4 -17
View File
@@ -1,5 +1,5 @@
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:timezone/timezone.dart'; import 'package:immich_mobile/utils/timezone.dart';
extension TZExtension on Asset { extension TZExtension on Asset {
/// Returns the created time of the asset from the exif info (if available) or from /// Returns the created time of the asset from the exif info (if available) or from
@@ -7,24 +7,11 @@ extension TZExtension on Asset {
/// the timezone offset in [Duration] /// the timezone offset in [Duration]
(DateTime, Duration) getTZAdjustedTimeAndOffset() { (DateTime, Duration) getTZAdjustedTimeAndOffset() {
DateTime dt = fileCreatedAt.toLocal(); DateTime dt = fileCreatedAt.toLocal();
if (exifInfo?.dateTimeOriginal != null) { if (exifInfo?.dateTimeOriginal != null) {
dt = exifInfo!.dateTimeOriginal!; return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone);
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); return (dt, dt.timeZoneOffset);
} }
} }
@@ -166,5 +166,6 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
mm: focalLength?.toDouble(), mm: focalLength?.toDouble(),
lens: lens, lens: lens,
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation), isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
exposureSeconds: ExifDtoConverter.exposureTimeToSeconds(exposureTime),
); );
} }
@@ -16,6 +16,12 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get orientation => integer().withDefault(const Constant(0))(); IntColumn get orientation => integer().withDefault(const Constant(0))();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()();
RealColumn get longitude => real().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -34,5 +40,8 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
width: width, width: width,
remoteId: remoteId, remoteId: remoteId,
orientation: orientation, orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
); );
} }
@@ -21,6 +21,9 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<String?> checksum, i0.Value<String?> checksum,
i0.Value<bool> isFavorite, i0.Value<bool> isFavorite,
i0.Value<int> orientation, i0.Value<int> orientation,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
}); });
typedef $$LocalAssetEntityTableUpdateCompanionBuilder = typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({ i1.LocalAssetEntityCompanion Function({
@@ -35,6 +38,9 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<String?> checksum, i0.Value<String?> checksum,
i0.Value<bool> isFavorite, i0.Value<bool> isFavorite,
i0.Value<int> orientation, i0.Value<int> orientation,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
}); });
class $$LocalAssetEntityTableFilterComposer class $$LocalAssetEntityTableFilterComposer
@@ -101,6 +107,21 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.orientation, column: $table.orientation,
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i0.ColumnFilters<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<double> get latitude => $composableBuilder(
column: $table.latitude,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<double> get longitude => $composableBuilder(
column: $table.longitude,
builder: (column) => i0.ColumnFilters(column),
);
} }
class $$LocalAssetEntityTableOrderingComposer class $$LocalAssetEntityTableOrderingComposer
@@ -166,6 +187,21 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.orientation, column: $table.orientation,
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i0.ColumnOrderings<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<double> get latitude => $composableBuilder(
column: $table.latitude,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<double> get longitude => $composableBuilder(
column: $table.longitude,
builder: (column) => i0.ColumnOrderings(column),
);
} }
class $$LocalAssetEntityTableAnnotationComposer class $$LocalAssetEntityTableAnnotationComposer
@@ -215,6 +251,17 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.orientation, column: $table.orientation,
builder: (column) => column, builder: (column) => column,
); );
i0.GeneratedColumn<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => column,
);
i0.GeneratedColumn<double> get latitude =>
$composableBuilder(column: $table.latitude, builder: (column) => column);
i0.GeneratedColumn<double> get longitude =>
$composableBuilder(column: $table.longitude, builder: (column) => column);
} }
class $$LocalAssetEntityTableTableManager class $$LocalAssetEntityTableTableManager
@@ -268,6 +315,9 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(), i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(), i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion( }) => i1.LocalAssetEntityCompanion(
name: name, name: name,
type: type, type: type,
@@ -280,6 +330,9 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum, checksum: checksum,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
), ),
createCompanionCallback: createCompanionCallback:
({ ({
@@ -294,6 +347,9 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(), i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(), i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert( }) => i1.LocalAssetEntityCompanion.insert(
name: name, name: name,
type: type, type: type,
@@ -306,6 +362,9 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum, checksum: checksum,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -473,6 +532,39 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
requiredDuringInsert: false, requiredDuringInsert: false,
defaultValue: const i4.Constant(0), defaultValue: const i4.Constant(0),
); );
static const i0.VerificationMeta _adjustmentTimeMeta =
const i0.VerificationMeta('adjustmentTime');
@override
late final i0.GeneratedColumn<DateTime> adjustmentTime =
i0.GeneratedColumn<DateTime>(
'adjustment_time',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _latitudeMeta = const i0.VerificationMeta(
'latitude',
);
@override
late final i0.GeneratedColumn<double> latitude = i0.GeneratedColumn<double>(
'latitude',
aliasedName,
true,
type: i0.DriftSqlType.double,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _longitudeMeta = const i0.VerificationMeta(
'longitude',
);
@override
late final i0.GeneratedColumn<double> longitude = i0.GeneratedColumn<double>(
'longitude',
aliasedName,
true,
type: i0.DriftSqlType.double,
requiredDuringInsert: false,
);
@override @override
List<i0.GeneratedColumn> get $columns => [ List<i0.GeneratedColumn> get $columns => [
name, name,
@@ -486,6 +578,9 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
checksum, checksum,
isFavorite, isFavorite,
orientation, orientation,
adjustmentTime,
latitude,
longitude,
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@@ -566,6 +661,27 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
), ),
); );
} }
if (data.containsKey('adjustment_time')) {
context.handle(
_adjustmentTimeMeta,
adjustmentTime.isAcceptableOrUnknown(
data['adjustment_time']!,
_adjustmentTimeMeta,
),
);
}
if (data.containsKey('latitude')) {
context.handle(
_latitudeMeta,
latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta),
);
}
if (data.containsKey('longitude')) {
context.handle(
_longitudeMeta,
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
);
}
return context; return context;
} }
@@ -624,6 +740,18 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.int, i0.DriftSqlType.int,
data['${effectivePrefix}orientation'], data['${effectivePrefix}orientation'],
)!, )!,
adjustmentTime: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
),
latitude: attachedDatabase.typeMapping.read(
i0.DriftSqlType.double,
data['${effectivePrefix}latitude'],
),
longitude: attachedDatabase.typeMapping.read(
i0.DriftSqlType.double,
data['${effectivePrefix}longitude'],
),
); );
} }
@@ -653,6 +781,9 @@ class LocalAssetEntityData extends i0.DataClass
final String? checksum; final String? checksum;
final bool isFavorite; final bool isFavorite;
final int orientation; final int orientation;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const LocalAssetEntityData({ const LocalAssetEntityData({
required this.name, required this.name,
required this.type, required this.type,
@@ -665,6 +796,9 @@ class LocalAssetEntityData extends i0.DataClass
this.checksum, this.checksum,
required this.isFavorite, required this.isFavorite,
required this.orientation, required this.orientation,
this.adjustmentTime,
this.latitude,
this.longitude,
}); });
@override @override
Map<String, i0.Expression> toColumns(bool nullToAbsent) { Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -692,6 +826,15 @@ class LocalAssetEntityData extends i0.DataClass
} }
map['is_favorite'] = i0.Variable<bool>(isFavorite); map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['orientation'] = i0.Variable<int>(orientation); map['orientation'] = i0.Variable<int>(orientation);
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime);
}
if (!nullToAbsent || latitude != null) {
map['latitude'] = i0.Variable<double>(latitude);
}
if (!nullToAbsent || longitude != null) {
map['longitude'] = i0.Variable<double>(longitude);
}
return map; return map;
} }
@@ -714,6 +857,9 @@ class LocalAssetEntityData extends i0.DataClass
checksum: serializer.fromJson<String?>(json['checksum']), checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']), isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']), orientation: serializer.fromJson<int>(json['orientation']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
); );
} }
@override @override
@@ -733,6 +879,9 @@ class LocalAssetEntityData extends i0.DataClass
'checksum': serializer.toJson<String?>(checksum), 'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite), 'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation), 'orientation': serializer.toJson<int>(orientation),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
}; };
} }
@@ -748,6 +897,9 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite, bool? isFavorite,
int? orientation, int? orientation,
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.LocalAssetEntityData( }) => i1.LocalAssetEntityData(
name: name ?? this.name, name: name ?? this.name,
type: type ?? this.type, type: type ?? this.type,
@@ -762,6 +914,11 @@ class LocalAssetEntityData extends i0.DataClass
checksum: checksum.present ? checksum.value : this.checksum, checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
); );
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) { LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData( return LocalAssetEntityData(
@@ -782,6 +939,11 @@ class LocalAssetEntityData extends i0.DataClass
orientation: data.orientation.present orientation: data.orientation.present
? data.orientation.value ? data.orientation.value
: this.orientation, : this.orientation,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
latitude: data.latitude.present ? data.latitude.value : this.latitude,
longitude: data.longitude.present ? data.longitude.value : this.longitude,
); );
} }
@@ -798,7 +960,10 @@ class LocalAssetEntityData extends i0.DataClass
..write('id: $id, ') ..write('id: $id, ')
..write('checksum: $checksum, ') ..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ') ..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation') ..write('orientation: $orientation, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -816,6 +981,9 @@ class LocalAssetEntityData extends i0.DataClass
checksum, checksum,
isFavorite, isFavorite,
orientation, orientation,
adjustmentTime,
latitude,
longitude,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -831,7 +999,10 @@ class LocalAssetEntityData extends i0.DataClass
other.id == this.id && other.id == this.id &&
other.checksum == this.checksum && other.checksum == this.checksum &&
other.isFavorite == this.isFavorite && other.isFavorite == this.isFavorite &&
other.orientation == this.orientation); other.orientation == this.orientation &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
} }
class LocalAssetEntityCompanion class LocalAssetEntityCompanion
@@ -847,6 +1018,9 @@ class LocalAssetEntityCompanion
final i0.Value<String?> checksum; final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite; final i0.Value<bool> isFavorite;
final i0.Value<int> orientation; final i0.Value<int> orientation;
final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
const LocalAssetEntityCompanion({ const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(), this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(), this.type = const i0.Value.absent(),
@@ -859,6 +1033,9 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(), this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(), this.orientation = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
}); });
LocalAssetEntityCompanion.insert({ LocalAssetEntityCompanion.insert({
required String name, required String name,
@@ -872,6 +1049,9 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(), this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(), this.orientation = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
}) : name = i0.Value(name), }) : name = i0.Value(name),
type = i0.Value(type), type = i0.Value(type),
id = i0.Value(id); id = i0.Value(id);
@@ -887,6 +1067,9 @@ class LocalAssetEntityCompanion
i0.Expression<String>? checksum, i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite, i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation, i0.Expression<int>? orientation,
i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
if (name != null) 'name': name, if (name != null) 'name': name,
@@ -900,6 +1083,9 @@ class LocalAssetEntityCompanion
if (checksum != null) 'checksum': checksum, if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite, if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation, if (orientation != null) 'orientation': orientation,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
}); });
} }
@@ -915,6 +1101,9 @@ class LocalAssetEntityCompanion
i0.Value<String?>? checksum, i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite, i0.Value<bool>? isFavorite,
i0.Value<int>? orientation, i0.Value<int>? orientation,
i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
}) { }) {
return i1.LocalAssetEntityCompanion( return i1.LocalAssetEntityCompanion(
name: name ?? this.name, name: name ?? this.name,
@@ -928,6 +1117,9 @@ class LocalAssetEntityCompanion
checksum: checksum ?? this.checksum, checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
); );
} }
@@ -969,6 +1161,15 @@ class LocalAssetEntityCompanion
if (orientation.present) { if (orientation.present) {
map['orientation'] = i0.Variable<int>(orientation.value); map['orientation'] = i0.Variable<int>(orientation.value);
} }
if (adjustmentTime.present) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime.value);
}
if (latitude.present) {
map['latitude'] = i0.Variable<double>(latitude.value);
}
if (longitude.present) {
map['longitude'] = i0.Variable<double>(longitude.value);
}
return map; return map;
} }
@@ -985,7 +1186,10 @@ class LocalAssetEntityCompanion
..write('id: $id, ') ..write('id: $id, ')
..write('checksum: $checksum, ') ..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ') ..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation') ..write('orientation: $orientation, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')')) ..write(')'))
.toString(); .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.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.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/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.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.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/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.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/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.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
@@ -95,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 13; int get schemaVersion => 14;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -185,6 +185,11 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v13.idxTrashedLocalAssetChecksum); await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum); await m.createIndex(v13.idxTrashedLocalAssetAlbum);
}, },
from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
), ),
); );
@@ -5485,6 +5485,462 @@ i1.GeneratedColumn<String> _column_95(String aliasedName) =>
false, false,
type: i1.DriftSqlType.string, 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,
_column_46,
_column_47,
],
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<DateTime> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
}
i1.GeneratedColumn<DateTime> _column_96(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'adjustment_time',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -5498,6 +5954,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11, 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, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13, required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -5561,6 +6018,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from12To13(migrator, schema); await from12To13(migrator, schema);
return 13; return 13;
case 13:
final schema = Schema14(database: database);
final migrator = i1.Migrator(database, schema);
await from13To14(migrator, schema);
return 14;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -5580,6 +6042,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11, 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, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13, required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -5594,5 +6057,6 @@ i1.OnUpgrade stepByStep({
from10To11: from10To11, from10To11: from10To11,
from11To12: from11To12, from11To12: from11To12,
from12To13: from12To13, from12To13: from12To13,
from13To14: from13To14,
), ),
); );
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -244,7 +246,56 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
} }
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) { Future<void> Function(Iterable<LocalAsset>) get _upsertAssets =>
CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid;
Future<void> _upsertAssetsDarwin(Iterable<LocalAsset> localAssets) async {
if (localAssets.isEmpty) {
return Future.value();
}
// Reset checksum if asset changed
await _db.batch((batch) async {
for (final asset in localAssets) {
final companion = LocalAssetEntityCompanion(
checksum: const Value(null),
adjustmentTime: Value(asset.adjustmentTime),
);
batch.update(
_db.localAssetEntity,
companion,
where: (row) => row.id.equals(asset.id) & row.adjustmentTime.isNotExp(Variable(asset.adjustmentTime)),
);
}
});
return _db.batch((batch) async {
for (final asset in localAssets) {
final companion = LocalAssetEntityCompanion.insert(
name: asset.name,
type: asset.type,
createdAt: Value(asset.createdAt),
updatedAt: Value(asset.updatedAt),
width: Value(asset.width),
height: Value(asset.height),
durationInSeconds: Value(asset.durationInSeconds),
id: asset.id,
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
latitude: Value(asset.latitude),
longitude: Value(asset.longitude),
adjustmentTime: Value(asset.adjustmentTime),
);
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
_db.localAssetEntity,
companion.copyWith(checksum: const Value(null)),
onConflict: DoUpdate((old) => companion),
);
}
});
}
Future<void> _upsertAssetsAndroid(Iterable<LocalAsset> localAssets) async {
if (localAssets.isEmpty) { if (localAssets.isEmpty) {
return Future.value(); return Future.value();
} }
@@ -260,6 +311,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
height: Value(asset.height), height: Value(asset.height),
durationInSeconds: Value(asset.durationInSeconds), durationInSeconds: Value(asset.durationInSeconds),
id: asset.id, id: asset.id,
checksum: const Value(null),
orientation: Value(asset.orientation), orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite), isFavorite: Value(asset.isFavorite),
); );
@@ -22,7 +22,7 @@ abstract final class ExifDtoConverter {
f: dto.fNumber?.toDouble(), f: dto.fNumber?.toDouble(),
mm: dto.focalLength?.toDouble(), mm: dto.focalLength?.toDouble(),
iso: dto.iso?.toInt(), iso: dto.iso?.toInt(),
exposureSeconds: _exposureTimeToSeconds(dto.exposureTime), exposureSeconds: exposureTimeToSeconds(dto.exposureTime),
); );
} }
@@ -36,15 +36,15 @@ abstract final class ExifDtoConverter {
return isRotated90CW || isRotated270CW; return isRotated90CW || isRotated270CW;
} }
static double? _exposureTimeToSeconds(String? s) { static double? exposureTimeToSeconds(String? second) {
if (s == null) { if (second == null) {
return null; return null;
} }
double? value = double.tryParse(s); double? value = double.tryParse(second);
if (value != null) { if (value != null) {
return value; return value;
} }
final parts = s.split("/"); final parts = second.split("/");
if (parts.length == 2) { if (parts.length == 2) {
final numerator = double.tryParse(parts.firstOrNull ?? "-"); final numerator = double.tryParse(parts.firstOrNull ?? "-");
final denominator = double.tryParse(parts.lastOrNull ?? "-"); final denominator = double.tryParse(parts.lastOrNull ?? "-");
@@ -3,7 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
+2 -6
View File
@@ -58,7 +58,7 @@ class SettingsPage extends StatelessWidget {
context.locale; context.locale;
return Scaffold( return Scaffold(
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()), appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()), body: context.isMobile ? const _MobileLayout() : const _TabletLayout(),
); );
} }
} }
@@ -89,11 +89,7 @@ class _MobileLayout extends StatelessWidget {
], ],
) )
.toList(); .toList();
return ListView( return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(top: 10.0, bottom: 16),
children: [...settings],
);
} }
} }
+1 -2
View File
@@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
@@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.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/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.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'; import 'package:immich_mobile/routing/router.dart';
@RoutePage() @RoutePage()
@@ -49,7 +49,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude); var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
selectedLatLng.value = currentLatLng; selectedLatLng.value = currentLatLng;
await controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng)); await controller.value?.animateCamera(CameraUpdate.newLatLngZoom(currentLatLng, 12));
} }
return MapThemeOverride( return MapThemeOverride(
@@ -66,7 +66,10 @@ class MapLocationPickerPage extends HookConsumerWidget {
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40)), borderRadius: BorderRadius.only(bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40)),
), ),
child: MapLibreMap( child: MapLibreMap(
initialCameraPosition: CameraPosition(target: initialLatLng, zoom: 12), initialCameraPosition: CameraPosition(
target: initialLatLng,
zoom: (initialLatLng.latitude == 0 && initialLatLng.longitude == 0) ? 1 : 12,
),
styleString: style, styleString: style,
onMapCreated: (mapController) => controller.value = mapController, onMapCreated: (mapController) => controller.value = mapController,
onStyleLoadedCallback: onStyleLoaded, onStyleLoadedCallback: onStyleLoaded,
+27 -1
View File
@@ -41,6 +41,9 @@ class PlatformAsset {
required this.durationInSeconds, required this.durationInSeconds,
required this.orientation, required this.orientation,
required this.isFavorite, required this.isFavorite,
this.adjustmentTime,
this.latitude,
this.longitude,
}); });
String id; String id;
@@ -63,8 +66,28 @@ class PlatformAsset {
bool isFavorite; bool isFavorite;
int? adjustmentTime;
double? latitude;
double? longitude;
List<Object?> _toList() { 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,
adjustmentTime,
latitude,
longitude,
];
} }
Object encode() { Object encode() {
@@ -84,6 +107,9 @@ class PlatformAsset {
durationInSeconds: result[7]! as int, durationInSeconds: result[7]! as int,
orientation: result[8]! as int, orientation: result[8]! as int,
isFavorite: result[9]! as bool, isFavorite: result[9]! as bool,
adjustmentTime: result[10] as int?,
latitude: result[11] as double?,
longitude: result[12] as double?,
); );
} }
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@RoutePage() @RoutePage()
@@ -129,6 +130,15 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString())); properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString()));
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id); 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: 'Album', value: albums.map((a) => a.name).join(', ')));
if (CurrentPlatform.isIOS) {
properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString()));
}
properties.add(
_PropertyItem(
label: 'GPS Coordinates',
value: asset.hasCoordinates ? '${asset.latitude}, ${asset.longitude}' : null,
),
);
} }
Future<void> _addRemoteAssetProperties(RemoteAsset asset) async { Future<void> _addRemoteAssetProperties(RemoteAsset asset) async {
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_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/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/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_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/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/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_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/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/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_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/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/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_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/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/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_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/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/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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/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) // used to allow performing unarchive action from different sources (without duplicating code)
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.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/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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -17,6 +16,9 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.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/routing/router.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
@@ -45,14 +47,28 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
List<RemoteAlbum> shownAlbums = []; List<RemoteAlbum> shownAlbums = [];
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all); AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true); AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Load albums when component mounts
WidgetsBinding.instance.addPostFrameCallback((_) { 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(); ref.read(remoteAlbumProvider.notifier).refresh();
}); });
@@ -82,6 +98,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
setState(() { setState(() {
isGrid = !isGrid; isGrid = !isGrid;
}); });
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
} }
void changeFilter(QuickFilterMode mode) { void changeFilter(QuickFilterMode mode) {
@@ -97,6 +114,10 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
this.sort = sort; 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(); await sortAlbums();
} }
@@ -181,6 +202,8 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
onToggleViewMode: toggleViewMode, onToggleViewMode: toggleViewMode,
onSortChanged: changeSort, onSortChanged: changeSort,
controller: menuController, controller: menuController,
currentSortMode: sort.mode,
currentIsReverse: sort.isReverse,
), ),
isGrid isGrid
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected) ? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
@@ -192,21 +215,46 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
} }
class _SortButton extends ConsumerStatefulWidget { class _SortButton extends ConsumerStatefulWidget {
const _SortButton(this.onSortChanged, {this.controller}); const _SortButton(
this.onSortChanged, {
required this.initialSortMode,
required this.initialIsReverse,
this.controller,
});
final Future<void> Function(AlbumSort) onSortChanged; final Future<void> Function(AlbumSort) onSortChanged;
final MenuController? controller; final MenuController? controller;
final AlbumSortMode initialSortMode;
final bool initialIsReverse;
@override @override
ConsumerState<_SortButton> createState() => _SortButtonState(); ConsumerState<_SortButton> createState() => _SortButtonState();
} }
class _SortButtonState extends ConsumerState<_SortButton> { class _SortButtonState extends ConsumerState<_SortButton> {
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified; late AlbumSortMode albumSortOption;
bool albumSortIsReverse = true; late bool albumSortIsReverse;
bool isSorting = false; bool isSorting = false;
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async { @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 {
final selected = albumSortOption == sortMode; final selected = albumSortOption == sortMode;
// Switch direction // Switch direction
if (selected) { if (selected) {
@@ -240,7 +288,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)), padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
), ),
consumeOutsideTap: true, consumeOutsideTap: true,
menuChildren: RemoteAlbumSortMode.values menuChildren: AlbumSortMode.values
.map( .map(
(sortMode) => MenuItemButton( (sortMode) => MenuItemButton(
leadingIcon: albumSortOption == sortMode leadingIcon: albumSortOption == sortMode
@@ -269,7 +317,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
), ),
), ),
child: Text( child: Text(
sortMode.key.t(context: context), sortMode.label.t(context: context),
style: context.textTheme.titleSmall?.copyWith( style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: albumSortOption == sortMode color: albumSortOption == sortMode
@@ -298,7 +346,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
: const Icon(Icons.keyboard_arrow_up_rounded), : const Icon(Icons.keyboard_arrow_up_rounded),
), ),
Text( Text(
albumSortOption.key.t(context: context), albumSortOption.label.t(context: context),
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225), color: context.colorScheme.onSurface.withAlpha(225),
@@ -465,6 +513,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
required this.isGrid, required this.isGrid,
required this.onToggleViewMode, required this.onToggleViewMode,
required this.onSortChanged, required this.onSortChanged,
required this.currentSortMode,
required this.currentIsReverse,
this.controller, this.controller,
}); });
@@ -472,6 +522,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
final VoidCallback onToggleViewMode; final VoidCallback onToggleViewMode;
final MenuController? controller; final MenuController? controller;
final Future<void> Function(AlbumSort) onSortChanged; final Future<void> Function(AlbumSort) onSortChanged;
final AlbumSortMode currentSortMode;
final bool currentIsReverse;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -481,7 +533,12 @@ class _QuickSortAndViewMode extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_SortButton(onSortChanged, controller: controller), _SortButton(
onSortChanged,
controller: controller,
initialSortMode: currentSortMode,
initialIsReverse: currentIsReverse,
),
IconButton( IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24), icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode, onPressed: onToggleViewMode,
@@ -7,7 +7,7 @@ import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -1,17 +1,7 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; 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:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.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 { class AssetViewerState {
final int backgroundOpacity; final int backgroundOpacity;
final bool showingBottomSheet; final bool showingBottomSheet;
@@ -9,6 +9,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; 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/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -23,6 +24,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.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'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = ''; const _kSeparator = '';
@@ -58,13 +60,21 @@ class AssetDetailBottomSheet extends ConsumerWidget {
class _AssetDetailBottomSheet extends ConsumerWidget { class _AssetDetailBottomSheet extends ConsumerWidget {
const _AssetDetailBottomSheet(); const _AssetDetailBottomSheet();
String _getDateTime(BuildContext ctx, BaseAsset asset) { String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
final dateTime = asset.createdAt.toLocal(); 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,
);
}
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
final timezone = dateTime.timeZoneOffset.isNegative final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
? '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'; return '$date$_kSeparator$time $timezone';
} }
@@ -214,8 +224,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color, color: context.textTheme.labelLarge?.color,
), ),
subtitle: _getFileInfo(asset, exifInfo), subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith( subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155), color: context.textTheme.labelMedium?.color?.withAlpha(200),
), ),
); );
}, },
@@ -231,8 +241,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color, color: context.textTheme.labelLarge?.color,
), ),
subtitle: _getFileInfo(asset, exifInfo), subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith( subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155), color: context.textTheme.labelMedium?.color?.withAlpha(200),
), ),
); );
} }
@@ -242,8 +252,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
children: [ children: [
// Asset Date and Time // Asset Date and Time
SheetTile( SheetTile(
title: _getDateTime(context, asset), title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), titleStyle: context.textTheme.labelLarge,
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
), ),
@@ -252,7 +262,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetLocationDetails(), const SheetLocationDetails(),
// Details header // Details header
SheetTile( SheetTile(
title: 'exif_bottom_sheet_details'.t(context: context), title: 'details'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith( titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200), color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -261,29 +271,33 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
// File info // File info
buildFileInfoTile(), buildFileInfoTile(),
// Camera info // Camera info
if (cameraTitle != null) if (cameraTitle != null) ...[
const SizedBox(height: 16),
SheetTile( SheetTile(
title: cameraTitle, title: cameraTitle,
titleStyle: context.textTheme.labelLarge, titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo), subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith( subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155), color: context.textTheme.labelMedium?.color?.withAlpha(200),
), ),
), ),
],
// Lens info // Lens info
if (lensTitle != null) if (lensTitle != null) ...[
const SizedBox(height: 16),
SheetTile( SheetTile(
title: lensTitle, title: lensTitle,
titleStyle: context.textTheme.labelLarge, titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo), subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith( subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155), color: context.textTheme.labelMedium?.color?.withAlpha(200),
), ),
), ),
],
// Appears in (Albums) // Appears in (Albums)
_buildAppearsInList(ref, context), Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off // padding at the bottom to avoid cut-off
const SizedBox(height: 100), const SizedBox(height: 100),
], ],
@@ -78,7 +78,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SheetTile( SheetTile(
title: 'exif_bottom_sheet_location'.t(context: context), title: 'location'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith( titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200), color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -102,7 +102,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
Text( Text(
coordinates, coordinates,
style: context.textTheme.labelMedium?.copyWith( style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(150), color: context.textTheme.labelMedium?.color?.withAlpha(200),
), ),
), ),
], ],
@@ -46,7 +46,7 @@ class SheetTile extends ConsumerWidget {
} else { } else {
titleWidget = Container( titleWidget = Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.only(left: 15), padding: const EdgeInsets.only(left: 15, right: 15),
child: Text(title, style: titleStyle), child: Text(title, style: titleStyle),
); );
} }
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -143,11 +143,13 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Flexible(
"enable_backup".t(context: context), child: Text(
style: context.textTheme.titleMedium?.copyWith( "enable_backup".t(context: context),
fontWeight: FontWeight.w600, style: context.textTheme.titleMedium?.copyWith(
color: context.primaryColor, fontWeight: FontWeight.w600,
color: context.primaryColor,
),
), ),
), ),
], ],
@@ -3,8 +3,8 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/memory.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/domain/utils/event_stream.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
@@ -150,7 +151,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
try { try {
bool syncSuccess = false; bool syncSuccess = false;
await Future.wait([ await Future.wait([
_safeRun(backgroundManager.syncLocal(), "syncLocal"), _safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"), _safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
]); ]);
if (syncSuccess) { if (syncSuccess) {
@@ -5,6 +5,7 @@ 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/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.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/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:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -70,7 +71,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<List<RemoteAlbum>> sortAlbums( Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { AlbumSortMode sortMode, {
bool isReverse = false, bool isReverse = false,
}) async { }) async {
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse); return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);
@@ -2,7 +2,6 @@ import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.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'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>( final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>(
@@ -10,11 +9,6 @@ final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectSta
dependencies: [timelineServiceProvider], dependencies: [timelineServiceProvider],
); );
class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
class MultiSelectState { class MultiSelectState {
final Set<BaseAsset> selectedAssets; final Set<BaseAsset> selectedAssets;
final Set<BaseAsset> lockedSelectionAssets; final Set<BaseAsset> lockedSelectionAssets;
+3 -11
View File
@@ -245,23 +245,15 @@ class AppRouter extends RootStackRouter {
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft, transitionsBuilder: TransitionsBuilders.slideLeft,
), ),
CustomRoute(page: FolderRoute.page, guards: [_authGuard], transitionsBuilder: TransitionsBuilders.fadeIn), AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
CustomRoute( AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]),
page: TrashRoute.page, AutoRoute(page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: SharedLinkRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]),
CustomRoute( CustomRoute(
page: ActivitiesRoute.page, page: ActivitiesRoute.page,
+12 -3
View File
@@ -15,6 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.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/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
@@ -175,9 +176,17 @@ class ActionService {
} }
final exifData = await _remoteAssetRepository.getExif(assetId); final exifData = await _remoteAssetRepository.getExif(assetId);
initialDate = asset.createdAt.toLocal();
offset = initialDate.timeZoneOffset; // Use EXIF timezone information if available (matching web app and display behavior)
timeZone = exifData?.timeZone; 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;
} }
final dateTime = await showDateTimePicker( final dateTime = await showDateTimePicker(
@@ -51,9 +51,10 @@ enum AppSettingsEnum<T> {
enableBackup<bool>(StoreKey.enableBackup, null, false), enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, 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), 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); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

Some files were not shown because too many files have changed in this diff Show More