Compare commits

..

1 Commits

Author SHA1 Message Date
Alex
a739be31f3 refactor: login form 2025-12-02 13:41:34 -06:00
202 changed files with 3459 additions and 13390 deletions

View File

@@ -4,6 +4,6 @@
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.7.4"
"prettier": "^3.5.3"
}
}

View File

@@ -108,7 +108,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: 'zulu'
java-version: '17'
@@ -222,7 +222,6 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
@@ -230,6 +229,13 @@ jobs:
run: |
pod install
- name: Install Fastlane
working-directory: ./mobile/ios
run: |
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}

View File

@@ -44,7 +44,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
@@ -105,7 +105,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
flavor: |
latest=false

View File

@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
image: ghcr.io/immich-app/mdq:main@sha256:73a05fc805dfd3bd29bebc08442aedfec5c419c5ad3421ec73edc5647233891a
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
category: '/language:${{matrix.language}}'

View File

@@ -69,7 +69,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -49,7 +49,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -68,7 +68,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -126,7 +126,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -144,7 +144,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -36,7 +36,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -159,7 +159,7 @@ jobs:
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@@ -52,7 +52,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -80,7 +80,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}

View File

@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -121,7 +121,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -168,7 +168,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -210,7 +210,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -254,7 +254,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -292,7 +292,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -340,7 +340,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -387,7 +387,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -426,7 +426,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -481,7 +481,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -572,7 +572,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
# python-version: 3.11
@@ -617,7 +617,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -668,7 +668,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -730,7 +730,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -31,7 +31,7 @@
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.7.4",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",

View File

@@ -58,6 +58,10 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
ulimits:
nofile:
soft: 1048576
hard: 1048576
ports:
- 9230:9230
- 9231:9231
@@ -96,6 +100,10 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
immich-server:
@@ -127,7 +135,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
#### Trigger Dump
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
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.
This dumps will count towards the last `X` dumps that will be kept based on your settings.

View File

@@ -21,12 +21,6 @@ server {
# allow large file uploads
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;
# increase body buffer to avoid limiting upload speed
client_body_buffer_size 1024k;
# Set headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -35,6 +29,8 @@ server {
# enable websockets: http://nginx.org/en/docs/http/websocket.html
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
# set timeout
@@ -44,8 +40,6 @@ server {
location / {
proxy_pass http://<backend_url>:2283;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# useful when using Let's Encrypt http-01 challenge

View File

@@ -52,7 +52,7 @@ Password login has been enabled.
Disable Maintenance Mode
```
immich-admin disable-maintenance-mode
immich-admin disable-maintenace-mode
Maintenance mode has been disabled.
```

View File

@@ -48,6 +48,7 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
**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
- 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

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-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
[job-status-page]: https://my.immich.app/admin/queues
[job-status-page]: https://my.immich.app/admin/jobs-status

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.
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.
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.
## Load balancing

View File

@@ -38,7 +38,7 @@
"@docusaurus/module-type-aliases": "~3.9.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.7.4",
"prettier": "^3.2.4",
"typescript": "^5.1.6"
},
"browserslist": {

View File

@@ -36,14 +36,14 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^34.0.0",
"exiftool-vendored": "^33.0.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
"oidc-provider": "^9.0.0",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",

View File

@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],

View File

@@ -7,7 +7,6 @@
"action_common_update": "Update",
"actions": "Actions",
"active": "Active",
"active_count": "Active: {count}",
"activity": "Activity",
"activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}",
"add": "Add",
@@ -78,6 +77,7 @@
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
"export_config_as_json_description": "Download the current system config as a JSON file",
"external_libraries_page_description": "Admin external library page",
"external_library_management": "External Library Management",
"face_detection": "Face detection",
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
"facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"Reset\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.",
@@ -111,9 +111,10 @@
"job_not_concurrency_safe": "This job is not concurrency-safe.",
"job_settings": "Job Settings",
"job_settings_description": "Manage job concurrency",
"job_status": "Job Status",
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
"jobs_failed": "{jobCount, plural, other {# failed}}",
"jobs_over_time": "Jobs over time",
"jobs_page_description": "Admin jobs page",
"library_created": "Created library: {library}",
"library_deleted": "Library deleted",
"library_details": "Library details",
@@ -276,14 +277,10 @@
"password_settings_description": "Manage password login settings",
"paths_validated_successfully": "All paths validated successfully",
"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)",
"refreshing_all_libraries": "Refreshing all libraries",
"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.",
"remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default",
"reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
@@ -652,7 +649,6 @@
"backup_options_page_title": "Backup options",
"backup_setting_subtitle": "Manage background and foreground upload settings",
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
@@ -719,7 +715,6 @@
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "Check Logs",
"checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City",
"clear": "Clear",
@@ -1107,7 +1102,6 @@
"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",
"failed": "Failed",
"failed_count": "Failed: {count}",
"failed_to_authenticate": "Failed to authenticate",
"failed_to_load_assets": "Failed to load assets",
"failed_to_load_folder": "Failed to load folder",
@@ -1168,7 +1162,6 @@
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"headers_settings_tile_title": "Custom proxy headers",
"height": "Height",
"hi_user": "Hi {name} ({email})",
"hide_all_people": "Hide all people",
"hide_gallery": "Hide gallery",
@@ -1291,7 +1284,6 @@
"local": "Local",
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_assets": "Local Assets",
"local_id": "Local ID",
"local_media_summary": "Local Media Summary",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
@@ -2217,12 +2209,10 @@
"viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"waiting": "Waiting",
"waiting_count": "Waiting: {count}",
"warning": "Warning",
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"wrong_pin_code": "Wrong PIN code",

View File

@@ -3,7 +3,7 @@ experimental_monorepo_root = true
[tools]
node = "24.11.1"
flutter = "3.35.7"
pnpm = "10.24.0"
pnpm = "10.22.0"
terragrunt = "0.93.10"
opentofu = "1.10.7"
java = "25.0.1"

View File

@@ -89,10 +89,7 @@ data class PlatformAsset (
val height: Long? = null,
val durationInSeconds: Long,
val orientation: Long,
val isFavorite: Boolean,
val adjustmentTime: Long? = null,
val latitude: Double? = null,
val longitude: Double? = null
val isFavorite: Boolean
)
{
companion object {
@@ -107,10 +104,7 @@ data class PlatformAsset (
val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long
val isFavorite = pigeonVar_list[9] as Boolean
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)
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
}
}
fun toList(): List<Any?> {
@@ -125,9 +119,6 @@ data class PlatformAsset (
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
)
}
override fun equals(other: Any?): Boolean {

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64

File diff suppressed because one or more lines are too long

View File

@@ -137,7 +137,8 @@
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>

View File

@@ -140,9 +140,6 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64
var orientation: Int64
var isFavorite: Bool
var adjustmentTime: Int64? = nil
var latitude: Double? = nil
var longitude: Double? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -157,9 +154,6 @@ struct PlatformAsset: Hashable {
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12])
return PlatformAsset(
id: id,
@@ -171,10 +165,7 @@ struct PlatformAsset: Hashable {
height: height,
durationInSeconds: durationInSeconds,
orientation: orientation,
isFavorite: isFavorite,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude
isFavorite: isFavorite
)
}
func toList() -> [Any?] {
@@ -189,9 +180,6 @@ struct PlatformAsset: Hashable {
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {

View File

@@ -12,10 +12,7 @@ extension PHAsset {
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0,
isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude
isFavorite: isFavorite
)
}
@@ -26,13 +23,6 @@ extension PHAsset {
var filename: 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
var originalFilename: String? {

View File

@@ -5,10 +5,6 @@ class LocalAsset extends BaseAsset {
final String? remoteAssetId;
final int orientation;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const LocalAsset({
required this.id,
String? remoteId,
@@ -23,9 +19,6 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
this.adjustmentTime,
this.latitude,
this.longitude,
}) : remoteAssetId = remoteId;
@override
@@ -40,8 +33,6 @@ class LocalAsset extends BaseAsset {
@override
String get heroTag => '${id}_${remoteId ?? checksum}';
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
@override
String toString() {
return '''LocalAsset {
@@ -56,9 +47,6 @@ class LocalAsset extends BaseAsset {
remoteId: ${remoteId ?? "<NA>"}
isFavorite: $isFavorite,
orientation: $orientation,
adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"},
}''';
}
@@ -67,23 +55,11 @@ class LocalAsset extends BaseAsset {
bool operator ==(Object other) {
if (other is! LocalAsset) return false;
if (identical(this, other)) return true;
return super == other &&
id == other.id &&
orientation == other.orientation &&
adjustmentTime == other.adjustmentTime &&
latitude == other.latitude &&
longitude == other.longitude;
return super == other && id == other.id && orientation == other.orientation;
}
@override
int get hashCode =>
super.hashCode ^
id.hashCode ^
remoteId.hashCode ^
orientation.hashCode ^
adjustmentTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
LocalAsset copyWith({
String? id,
@@ -98,9 +74,6 @@ class LocalAsset extends BaseAsset {
int? durationInSeconds,
bool? isFavorite,
int? orientation,
DateTime? adjustmentTime,
double? latitude,
double? longitude,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -115,9 +88,6 @@ class LocalAsset extends BaseAsset {
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
}

View File

@@ -37,7 +37,7 @@ class ExifInfo {
String get fNumber => f == null ? "" : f!.toStringAsFixed(1);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(1);
const ExifInfo({
this.assetId,

View File

@@ -286,23 +286,11 @@ class LocalSyncService {
}
bool _assetsEqual(LocalAsset a, LocalAsset b) {
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 &&
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds &&
a.latitude == b.latitude &&
a.longitude == b.longitude;
a.durationInSeconds == b.durationInSeconds;
}
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
@@ -388,8 +376,5 @@ extension PlatformToLocalAsset on PlatformAsset {
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
orientation: orientation,
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
);
}

View File

@@ -166,6 +166,5 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
mm: focalLength?.toDouble(),
lens: lens,
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
exposureSeconds: ExifDtoConverter.exposureTimeToSeconds(exposureTime),
);
}

View File

@@ -16,12 +16,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get orientation => integer().withDefault(const Constant(0))();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()();
RealColumn get longitude => real().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -40,8 +34,5 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
width: width,
remoteId: remoteId,
orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
);
}

View File

@@ -21,9 +21,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
});
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({
@@ -38,9 +35,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
});
class $$LocalAssetEntityTableFilterComposer
@@ -107,21 +101,6 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.orientation,
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
@@ -187,21 +166,6 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.orientation,
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
@@ -251,17 +215,6 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.orientation,
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
@@ -315,9 +268,6 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion(
name: name,
type: type,
@@ -330,9 +280,6 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum,
isFavorite: isFavorite,
orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
createCompanionCallback:
({
@@ -347,9 +294,6 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<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(
name: name,
type: type,
@@ -362,9 +306,6 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum,
isFavorite: isFavorite,
orientation: orientation,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -532,39 +473,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
requiredDuringInsert: false,
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
List<i0.GeneratedColumn> get $columns => [
name,
@@ -578,9 +486,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
checksum,
isFavorite,
orientation,
adjustmentTime,
latitude,
longitude,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -661,27 +566,6 @@ 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;
}
@@ -740,18 +624,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.int,
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'],
),
);
}
@@ -781,9 +653,6 @@ class LocalAssetEntityData extends i0.DataClass
final String? checksum;
final bool isFavorite;
final int orientation;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const LocalAssetEntityData({
required this.name,
required this.type,
@@ -796,9 +665,6 @@ class LocalAssetEntityData extends i0.DataClass
this.checksum,
required this.isFavorite,
required this.orientation,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -826,15 +692,6 @@ class LocalAssetEntityData extends i0.DataClass
}
map['is_favorite'] = i0.Variable<bool>(isFavorite);
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;
}
@@ -857,9 +714,6 @@ class LocalAssetEntityData extends i0.DataClass
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
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
@@ -879,9 +733,6 @@ class LocalAssetEntityData extends i0.DataClass
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
};
}
@@ -897,9 +748,6 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite,
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(
name: name ?? this.name,
type: type ?? this.type,
@@ -914,11 +762,6 @@ class LocalAssetEntityData extends i0.DataClass
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
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) {
return LocalAssetEntityData(
@@ -939,11 +782,6 @@ class LocalAssetEntityData extends i0.DataClass
orientation: data.orientation.present
? data.orientation.value
: 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,
);
}
@@ -960,10 +798,7 @@ class LocalAssetEntityData extends i0.DataClass
..write('id: $id, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write('orientation: $orientation')
..write(')'))
.toString();
}
@@ -981,9 +816,6 @@ class LocalAssetEntityData extends i0.DataClass
checksum,
isFavorite,
orientation,
adjustmentTime,
latitude,
longitude,
);
@override
bool operator ==(Object other) =>
@@ -999,10 +831,7 @@ class LocalAssetEntityData extends i0.DataClass
other.id == this.id &&
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
other.orientation == this.orientation);
}
class LocalAssetEntityCompanion
@@ -1018,9 +847,6 @@ class LocalAssetEntityCompanion
final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite;
final i0.Value<int> orientation;
final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1033,9 +859,6 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(),
this.isFavorite = 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({
required String name,
@@ -1049,9 +872,6 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(),
this.isFavorite = 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),
type = i0.Value(type),
id = i0.Value(id);
@@ -1067,9 +887,6 @@ class LocalAssetEntityCompanion
i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation,
i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1083,9 +900,6 @@ class LocalAssetEntityCompanion
if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
});
}
@@ -1101,9 +915,6 @@ class LocalAssetEntityCompanion
i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite,
i0.Value<int>? orientation,
i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
}) {
return i1.LocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1117,9 +928,6 @@ class LocalAssetEntityCompanion
checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
@@ -1161,15 +969,6 @@ class LocalAssetEntityCompanion
if (orientation.present) {
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;
}
@@ -1186,10 +985,7 @@ class LocalAssetEntityCompanion
..write('id: $id, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write('orientation: $orientation')
..write(')'))
.toString();
}

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
@@ -20,7 +21,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
@@ -95,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 14;
int get schemaVersion => 13;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -185,11 +185,6 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
},
from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
),
);

View File

@@ -5485,462 +5485,6 @@ i1.GeneratedColumn<String> _column_95(String aliasedName) =>
false,
type: i1.DriftSqlType.string,
);
final class Schema14 extends i0.VersionedSchema {
Schema14({required super.database}) : super(version: 14);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape24 localAssetEntity = Shape24(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_96,
_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({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -5954,7 +5498,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -6018,11 +5561,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from12To13(migrator, schema);
return 13;
case 13:
final schema = Schema14(database: database);
final migrator = i1.Migrator(database, schema);
await from13To14(migrator, schema);
return 14;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -6042,7 +5580,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -6057,6 +5594,5 @@ i1.OnUpgrade stepByStep({
from10To11: from10To11,
from11To12: from11To12,
from12To13: from12To13,
from13To14: from13To14,
),
);

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -246,56 +244,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
}
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 {
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) {
if (localAssets.isEmpty) {
return Future.value();
}
@@ -311,7 +260,6 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
height: Value(asset.height),
durationInSeconds: Value(asset.durationInSeconds),
id: asset.id,
checksum: const Value(null),
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
);

View File

@@ -22,7 +22,7 @@ abstract final class ExifDtoConverter {
f: dto.fNumber?.toDouble(),
mm: dto.focalLength?.toDouble(),
iso: dto.iso?.toInt(),
exposureSeconds: exposureTimeToSeconds(dto.exposureTime),
exposureSeconds: _exposureTimeToSeconds(dto.exposureTime),
);
}
@@ -36,15 +36,15 @@ abstract final class ExifDtoConverter {
return isRotated90CW || isRotated270CW;
}
static double? exposureTimeToSeconds(String? second) {
if (second == null) {
static double? _exposureTimeToSeconds(String? s) {
if (s == null) {
return null;
}
double? value = double.tryParse(second);
double? value = double.tryParse(s);
if (value != null) {
return value;
}
final parts = second.split("/");
final parts = s.split("/");
if (parts.length == 2) {
final numerator = double.tryParse(parts.firstOrNull ?? "-");
final denominator = double.tryParse(parts.lastOrNull ?? "-");

View File

@@ -0,0 +1,7 @@
class OAuthLoginData {
final String serverUrl;
final String state;
final String codeVerifier;
const OAuthLoginData({required this.serverUrl, required this.state, required this.codeVerifier});
}

View File

@@ -98,7 +98,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
),
),
Text(
"backup_upload_details_page_more_details".t(context: context),
'Tap for more details',
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
@@ -239,20 +239,14 @@ class FileDetailDialog extends ConsumerWidget {
const SizedBox(height: 24),
if (asset != null) ...[
_buildInfoSection(context, [
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
_buildInfoRow(context, "local_id".t(context: context), asset.id),
_buildInfoRow(
context,
"file_size".t(context: context),
formatHumanReadableBytes(uploadStatus.fileSize, 2),
),
if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"),
if (asset.height != null)
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
if (asset.checksum != null)
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
_buildInfoRow(context, "Filename", path.basename(uploadStatus.filename)),
_buildInfoRow(context, "Local ID", asset.id),
_buildInfoRow(context, "File Size", formatHumanReadableBytes(uploadStatus.fileSize, 2)),
if (asset.width != null) _buildInfoRow(context, "Width", "${asset.width}px"),
if (asset.height != null) _buildInfoRow(context, "Height", "${asset.height}px"),
_buildInfoRow(context, "Created At", asset.createdAt.toString()),
_buildInfoRow(context, "Updated At", asset.updatedAt.toString()),
if (asset.checksum != null) _buildInfoRow(context, "Checksum", asset.checksum!),
]),
],
],

View File

@@ -27,7 +27,7 @@ class LoginPage extends HookConsumerWidget {
});
return Scaffold(
body: LoginForm(),
body: const LoginForm(),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),

View File

@@ -49,7 +49,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
selectedLatLng.value = currentLatLng;
await controller.value?.animateCamera(CameraUpdate.newLatLngZoom(currentLatLng, 12));
await controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
}
return MapThemeOverride(
@@ -66,10 +66,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40)),
),
child: MapLibreMap(
initialCameraPosition: CameraPosition(
target: initialLatLng,
zoom: (initialLatLng.latitude == 0 && initialLatLng.longitude == 0) ? 1 : 12,
),
initialCameraPosition: CameraPosition(target: initialLatLng, zoom: 12),
styleString: style,
onMapCreated: (mapController) => controller.value = mapController,
onStyleLoadedCallback: onStyleLoaded,

View File

@@ -41,9 +41,6 @@ class PlatformAsset {
required this.durationInSeconds,
required this.orientation,
required this.isFavorite,
this.adjustmentTime,
this.latitude,
this.longitude,
});
String id;
@@ -66,28 +63,8 @@ class PlatformAsset {
bool isFavorite;
int? adjustmentTime;
double? latitude;
double? longitude;
List<Object?> _toList() {
return <Object?>[
id,
name,
type,
createdAt,
updatedAt,
width,
height,
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
];
return <Object?>[id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite];
}
Object encode() {
@@ -107,9 +84,6 @@ class PlatformAsset {
durationInSeconds: result[7]! as int,
orientation: result[8]! as int,
isFavorite: result[9]! as bool,
adjustmentTime: result[10] as int?,
latitude: result[11] as double?,
longitude: result[12] as double?,
);
}

View File

@@ -0,0 +1,173 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
final _features = [
_Feature(
name: 'Main Timeline',
icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
),
_Feature(
name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded,
onTap: (ctx, ref) async {
final user = ref.watch(currentUserProvider);
if (user == null) {
return Future.value();
}
final assets = await ref.read(remoteAssetRepositoryProvider).getSome(user.id);
final selectedAssets = await ctx.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: assets.toSet()),
);
Logger("FeaturesInDevelopment").fine("Selected ${selectedAssets?.length ?? 0} assets");
return Future.value();
},
),
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
_Feature(
name: 'Sync Local',
icon: Icons.photo_album_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(),
),
_Feature(
name: 'Sync Local Full (1)',
icon: Icons.photo_library_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
),
_Feature(
name: 'Hash Local Assets (2)',
icon: Icons.numbers_outlined,
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
),
_Feature(
name: 'Sync Remote (3)',
icon: Icons.refresh_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(),
),
_Feature(
name: 'WAL Checkpoint',
icon: Icons.save_rounded,
onTap: (_, ref) => ref.read(driftProvider).customStatement("pragma wal_checkpoint(truncate)"),
),
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
_Feature(
name: 'Clear Delta Checkpoint',
icon: Icons.delete_rounded,
onTap: (_, ref) => ref.read(nativeSyncApiProvider).clearSyncCheckpoint(),
),
_Feature(
name: 'Clear Local Data',
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
icon: Icons.delete_forever_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
await db.localAssetEntity.deleteAll();
await db.localAlbumEntity.deleteAll();
await db.localAlbumAssetEntity.deleteAll();
},
),
_Feature(
name: 'Clear Remote Data',
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
icon: Icons.delete_sweep_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
await db.remoteAssetEntity.deleteAll();
await db.remoteExifEntity.deleteAll();
await db.remoteAlbumEntity.deleteAll();
await db.remoteAlbumUserEntity.deleteAll();
await db.remoteAlbumAssetEntity.deleteAll();
await db.memoryEntity.deleteAll();
await db.memoryAssetEntity.deleteAll();
await db.stackEntity.deleteAll();
await db.personEntity.deleteAll();
await db.assetFaceEntity.deleteAll();
},
),
_Feature(
name: 'Local Media Summary',
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
icon: Icons.table_chart_rounded,
onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
),
_Feature(
name: 'Remote Media Summary',
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
icon: Icons.summarize_rounded,
onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
),
_Feature(
name: 'Reset Sqlite',
icon: Icons.table_view_rounded,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
onTap: (_, ref) async {
final drift = ref.read(driftProvider);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
final migrator = drift.createMigrator();
for (final entity in drift.allSchemaEntities) {
await migrator.drop(entity);
await migrator.create(entity);
}
},
),
];
@RoutePage()
class FeatInDevPage extends StatelessWidget {
const FeatInDevPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('features_in_development'.tr()), centerTitle: true),
body: Column(
children: [
Flexible(
flex: 1,
child: ListView.builder(
itemBuilder: (_, index) {
final feat = _features[index];
return Consumer(
builder: (ctx, ref, _) => ListTile(
title: Text(feat.name, style: feat.style),
trailing: Icon(feat.icon),
visualDensity: VisualDensity.compact,
onTap: () => unawaited(feat.onTap(ctx, ref)),
),
);
},
itemCount: _features.length,
),
),
const Divider(height: 0),
],
),
);
}
}
class _Feature {
const _Feature({required this.name, required this.icon, required this.onTap, this.style});
final String name;
final IconData icon;
final TextStyle? style;
final Future<void> Function(BuildContext, WidgetRef _) onTap;
}

View File

@@ -1,51 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_ui/immich_ui.dart';
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
final children = <Widget>[];
final items = [
(variant: ImmichVariant.filled, title: "Filled Variant"),
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
];
for (final (:variant, :title) in items) {
children.add(Text(title));
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
}
return children;
}
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
const ImmichUIShowcasePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Immich UI Showcase')),
body: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
spacing: 10,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("IconButton", style: context.textTheme.titleLarge),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
),
Text("CloseButton", style: context.textTheme.titleLarge),
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
],
),
),
),
);
}
}

View File

@@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
child: Scaffold(
appBar: AppBar(
title: Text(album.name),
actions: [const LikeActivityActionButton(iconOnly: true)],
actions: [const LikeActivityActionButton(menuItem: true)],
actionsPadding: const EdgeInsets.only(right: 8),
),
body: activities.widgetWhen(

View File

@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@RoutePage()
@@ -130,15 +129,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString()));
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
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 {

View File

@@ -27,19 +27,8 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
bool isAlbumTitleTextFieldFocus = false;
Set<BaseAsset> selectedAssets = {};
@override
void initState() {
super.initState();
albumTitleController.addListener(_onTitleChanged);
}
void _onTitleChanged() {
setState(() {});
}
@override
void dispose() {
albumTitleController.removeListener(_onTitleChanged);
albumTitleController.dispose();
albumDescriptionController.dispose();
albumTitleTextFieldFocusNode.dispose();

View File

@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_ui/immich_ui.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
@@ -31,13 +30,11 @@ class DriftCropImagePage extends HookWidget {
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: const ImmichCloseButton(),
leading: CloseButton(color: context.primaryColor),
actions: [
ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onTap: () async {
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
@@ -75,17 +72,17 @@ class DriftCropImagePage extends HookWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateLeft(),
IconButton(
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateLeft();
},
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateRight(),
IconButton(
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateRight();
},
),
],
),

View File

@@ -21,36 +21,12 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme});
class AddActionButton extends ConsumerWidget {
const AddActionButton({super.key});
final ThemeData? originalTheme;
@override
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
}
class _AddActionButtonState extends ConsumerState<AddActionButton> {
void _handleMenuSelection(AddToMenuItem selected) {
switch (selected) {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
List<Widget> _buildMenuChildren() {
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async {
final asset = ref.read(currentAssetNotifier);
if (asset == null) return [];
if (asset == null) return;
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
@@ -59,50 +35,84 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final hasRemote = asset is RemoteAsset;
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
final menuItemHeight = 30.0;
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
final List<PopupMenuEntry<AddToMenuItem>> items = [
PopupMenuItem(
enabled: false,
textStyle: context.textTheme.labelMedium,
height: 40,
child: Text("add_to_bottom_bar".tr()),
),
BaseActionButton(
iconData: Icons.photo_album_outlined,
label: "album".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.album,
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())),
),
const PopupMenuDivider(),
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
if (isOwner) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
if (showArchive)
BaseActionButton(
iconData: Icons.archive_outlined,
label: "archive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.archive,
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())),
),
if (showUnarchive)
BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.unarchive,
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())),
),
BaseActionButton(
iconData: Icons.lock_outline,
label: "locked_folder".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.lockedFolder,
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())),
),
],
];
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
context: context,
color: context.themeData.scaffoldBackgroundColor,
position: _menuPosition(context),
items: items,
popUpAnimationStyle: AnimationStyle.noAnimation,
);
if (selected == null) {
return;
}
switch (selected) {
case AddToMenuItem.album:
_openAlbumSelector(context, ref);
break;
case AddToMenuItem.archive:
await performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
void _openAlbumSelector() {
RelativeRect _menuPosition(BuildContext context) {
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox) {
return RelativeRect.fill;
}
final size = renderObject.size;
final position = renderObject.localToGlobal(Offset.zero);
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
}
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
final currentAsset = ref.read(currentAssetNotifier);
if (currentAsset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
@@ -110,8 +120,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
}
final List<Widget> slivers = [
const CreateAlbumButton(),
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album)),
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)),
];
showModalBottomSheet(
@@ -132,7 +141,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
);
}
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async {
final latest = ref.read(currentAssetNotifier);
if (latest == null) {
@@ -156,9 +165,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
}
if (!context.mounted) {
@@ -168,38 +174,17 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: widget.originalTheme != null
? [
Theme(
data: widget.originalTheme!,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
),
]
: _buildMenuChildren(),
builder: (context, controller, child) {
return Builder(
builder: (buttonContext) {
return BaseActionButton(
iconData: Icons.add,
label: "add_to_bottom_bar".tr(),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
onPressed: () => _showAddOptions(buttonContext, ref),
);
},
);

View File

@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class BaseActionButton extends ConsumerWidget {
class BaseActionButton extends StatelessWidget {
const BaseActionButton({
super.key,
required this.label,
@@ -12,7 +11,6 @@ class BaseActionButton extends ConsumerWidget {
this.onLongPressed,
this.maxWidth = 90.0,
this.minWidth,
this.iconOnly = false,
this.menuItem = false,
});
@@ -21,42 +19,25 @@ class BaseActionButton extends ConsumerWidget {
final Color? iconColor;
final double maxWidth;
final double? minWidth;
/// When true, renders only an IconButton without text label
final bool iconOnly;
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
final bool menuItem;
final void Function()? onPressed;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (iconOnly) {
if (menuItem) {
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
);
}
if (menuItem) {
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(

View File

@@ -7,9 +7,8 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
const CastActionButton({super.key, this.menuItem = true});
final bool iconOnly;
final bool menuItem;
@override
@@ -23,7 +22,6 @@ class CastActionButton extends ConsumerWidget {
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -10,9 +10,8 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class DownloadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
if (!context.mounted) {
@@ -39,7 +38,6 @@ class DownloadActionButton extends ConsumerWidget {
iconData: Icons.download,
maxWidth: 95,
label: "download".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager),
);

View File

@@ -10,10 +10,9 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
const FavoriteActionButton({super.key, required this.source, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -45,7 +44,6 @@ class FavoriteActionButton extends ConsumerWidget {
return BaseActionButton(
iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);

View File

@@ -12,9 +12,8 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/user.provider.dart';
class LikeActivityActionButton extends ConsumerWidget {
const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
const LikeActivityActionButton({super.key, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
@@ -50,7 +49,6 @@ class LikeActivityActionButton extends ConsumerWidget {
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
label: "like".t(context: context),
onPressed: () => onTap(liked),
iconOnly: iconOnly,
menuItem: menuItem,
);
},
@@ -59,7 +57,6 @@ class LikeActivityActionButton extends ConsumerWidget {
loading: () => BaseActionButton(
iconData: Icons.favorite_border,
label: "like".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
),
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),

View File

@@ -5,9 +5,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoActionButton extends ConsumerWidget {
const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false});
const MotionPhotoActionButton({super.key, this.menuItem = true});
final bool iconOnly;
final bool menuItem;
@override
@@ -18,7 +17,6 @@ class MotionPhotoActionButton extends ConsumerWidget {
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
label: "play_motion_photo".t(context: context),
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -10,10 +10,9 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnFavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -46,7 +45,6 @@ class UnFavoriteActionButton extends ConsumerWidget {
iconData: Icons.favorite_rounded,
label: "unfavorite".t(context: context),
onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -12,10 +12,8 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
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/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
@@ -768,68 +766,3 @@ class AddToAlbumHeader extends ConsumerWidget {
);
}
}
class CreateAlbumButton extends ConsumerWidget {
const CreateAlbumButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
var albumName = await showDialog<String?>(context: context, builder: (context) => const NewAlbumNameModal());
if (albumName == null) {
return;
}
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbum(title: albumName, assetIds: [asset.remoteId!]);
if (album == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
return;
}
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(asset.remoteId!));
context.pop();
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("add_to_album", style: context.textTheme.titleSmall).tr(),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onCreateAlbum,
icon: Icon(Icons.add, color: context.primaryColor),
label: Text(
"common_create_new_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
],
),
),
);
}
}

View File

@@ -1,53 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class NewAlbumNameModal extends StatefulWidget {
const NewAlbumNameModal({super.key});
@override
State<NewAlbumNameModal> createState() => _NewAlbumNameModalState();
}
class _NewAlbumNameModalState extends State<NewAlbumNameModal> {
TextEditingController nameController = TextEditingController();
@override
void dispose() {
nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("album_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: nameController,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () {
context.pop(nameController.text.trim());
},
child: Text(
"create_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View File

@@ -38,13 +38,11 @@ class ViewerBottomBar extends ConsumerWidget {
opacity = 0;
}
final originalTheme = context.themeData;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (asset.hasRemote) const AddActionButton(),
if (isOwner) ...[
asset.isLocalOnly

View File

@@ -251,8 +251,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
),
);
},
@@ -268,8 +268,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
),
);
}
@@ -280,7 +280,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
// Asset Date and Time
SheetTile(
title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.labelLarge,
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
),
@@ -289,7 +289,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'details'.t(context: context).toUpperCase(),
title: 'exif_bottom_sheet_details'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
@@ -298,33 +298,29 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
// File info
buildFileInfoTile(),
// Camera info
if (cameraTitle != null) ...[
const SizedBox(height: 16),
if (cameraTitle != null)
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
),
),
],
// Lens info
if (lensTitle != null) ...[
const SizedBox(height: 16),
if (lensTitle != null)
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
_buildAppearsInList(ref, context),
// padding at the bottom to avoid cut-off
const SizedBox(height: 100),
],

View File

@@ -78,7 +78,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetTile(
title: 'location'.t(context: context).toUpperCase(),
title: 'exif_bottom_sheet_location'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
@@ -102,7 +102,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
Text(
coordinates,
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
color: context.textTheme.labelMedium?.color?.withAlpha(150),
),
),
],

View File

@@ -46,7 +46,7 @@ class SheetTile extends ConsumerWidget {
} else {
titleWidget = Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 15, right: 15),
padding: const EdgeInsets.only(left: 15),
child: Text(title, style: titleStyle),
);
}

View File

@@ -4,19 +4,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@@ -35,6 +41,15 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final showViewInTimelineButton =
timelineOrigin != TimelineOrigin.main &&
timelineOrigin != TimelineOrigin.deepLink &&
timelineOrigin != TimelineOrigin.trash &&
timelineOrigin != TimelineOrigin.archive &&
timelineOrigin != TimelineOrigin.localAlbum &&
isOwner;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -47,10 +62,11 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
opacity = 0;
}
final originalTheme = context.themeData;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
@@ -58,16 +74,28 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
},
),
if (showViewInTimelineButton)
IconButton(
onPressed: () async {
await context.maybePop();
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
},
icon: const Icon(Icons.image_search),
tooltip: 'view_in_timeline'.t(context: context),
),
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
const _KebabMenu(),
];
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
const _KebabMenu(),
];
return IgnorePointer(
ignoring: opacity < 255,
@@ -94,6 +122,20 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
Size get preferredSize => const Size.fromHeight(60.0);
}
class _KebabMenu extends ConsumerWidget {
const _KebabMenu();
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
onPressed: () {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
},
icon: const Icon(Icons.more_vert_rounded),
);
}
}
class _AppBarBackButton extends ConsumerWidget {
const _AppBarBackButton();

View File

@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
@@ -105,12 +104,7 @@ class NativeVideoViewer extends HookConsumerWidget {
throw Exception('No file found for the video');
}
// Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
final source = await VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
final source = await VideoSource.init(path: file.path, type: VideoSourceType.file);
return source;
}

View File

@@ -1,67 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
class ViewerKebabMenu extends ConsumerWidget {
const ViewerKebabMenu({super.key, this.originalTheme});
final ThemeData? originalTheme;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final kebabContext = ViewerKebabMenuButtonContext(
asset: asset,
isOwner: isOwner,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
);
final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref);
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: const Icon(Icons.more_vert_rounded),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View File

@@ -324,11 +324,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
const bottomSheetOpenModifier = 120.0;
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding);
final grid = CustomScrollView(
primary: true,
@@ -351,7 +347,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false,
),
),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
);

View File

@@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.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/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
@@ -151,7 +150,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
try {
bool syncSuccess = false;
await Future.wait([
_safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
_safeRun(backgroundManager.syncLocal(), "syncLocal"),
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
]);
if (syncSuccess) {

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
@@ -26,7 +25,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final AppRouter router;
final UploadService _uploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
_uploadService.taskStatusStream.listen(_updateUploadStatus);
@@ -88,8 +86,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
for (final attachment in state)
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
];
_logger.fine("Upload failed for asset: ${task.task.filename}, exception: ${task.exception}");
}
void _taskProgressCallback(TaskProgressUpdate update) {

View File

@@ -12,13 +12,29 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class ServerAuthSettings {
final String endpoint;
final bool isOAuthEnabled;
final bool isPasswordLoginEnabled;
final String oAuthButtonText;
const ServerAuthSettings({
required this.endpoint,
required this.isOAuthEnabled,
required this.isPasswordLoginEnabled,
required this.oAuthButtonText,
});
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
ref.watch(authServiceProvider),
@@ -27,6 +43,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
ref.watch(uploadServiceProvider),
ref.watch(secureStorageServiceProvider),
ref.watch(widgetServiceProvider),
ref.watch(serverInfoServiceProvider),
);
});
@@ -37,6 +54,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
final UploadService _uploadService;
final SecureStorageService _secureStorageService;
final WidgetService _widgetService;
final ServerInfoService _serverInfoService;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
@@ -48,6 +66,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
this._uploadService,
this._secureStorageService,
this._widgetService,
this._serverInfoService,
) : super(
const AuthState(
deviceId: "",
@@ -64,6 +83,27 @@ class AuthNotifier extends StateNotifier<AuthState> {
return _authService.validateServerUrl(url);
}
Future<ServerAuthSettings?> getServerAuthSettings(String serverUrl) async {
final sanitizedUrl = sanitizeUrl(serverUrl);
final encodedUrl = punycodeEncodeUrl(sanitizedUrl);
final endpoint = await _authService.validateServerUrl(encodedUrl);
final features = await _serverInfoService.getServerFeatures();
final config = await _serverInfoService.getServerConfig();
if (features == null || config == null) {
return null;
}
return ServerAuthSettings(
endpoint: endpoint,
isOAuthEnabled: features.oauthEnabled,
isPasswordLoginEnabled: features.passwordLogin,
oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth',
);
}
/// Validating the url is the alternative connecting server url without
/// saving the information to the local database
Future<bool> validateAuxilaryServerUrl(String url) async {

View File

@@ -1,5 +1,27 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/oauth.service.dart';
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/oauth.service.dart';
import 'package:openapi/api.dart';
export 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));
final oAuthProvider = StateNotifierProvider<OAuthNotifier, AsyncValue<void>>(
(ref) => OAuthNotifier(ref.watch(oAuthServiceProvider)),
);
class OAuthNotifier extends StateNotifier<AsyncValue<void>> {
final OAuthService _oAuthService;
OAuthNotifier(this._oAuthService) : super(const AsyncValue.data(null));
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) {
return _oAuthService.getOAuthLoginData(serverUrl);
}
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
return _oAuthService.completeOAuthLogin(oAuthData);
}
}

View File

@@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
@@ -167,7 +167,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: ChangePasswordRoute.page),
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
AutoRoute(
CustomRoute(
page: TabControllerRoute.page,
guards: [_authGuard, _duplicateGuard],
children: [
@@ -176,8 +176,9 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
AutoRoute(
CustomRoute(
page: TabShellRoute.page,
guards: [_authGuard, _duplicateGuard],
children: [
@@ -186,6 +187,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
page: GalleryViewerRoute.page,
@@ -286,6 +288,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LockedRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: PinAuthRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: FeatInDevRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LocalMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupRoute.page, guards: [_authGuard, _duplicateGuard]),
@@ -337,7 +340,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -1648,6 +1648,22 @@ class FavoritesRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [FeatInDevPage]
class FeatInDevRoute extends PageRouteInfo<void> {
const FeatInDevRoute({List<PageRouteInfo>? children})
: super(FeatInDevRoute.name, initialChildren: children);
static const String name = 'FeatInDevRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const FeatInDevPage();
},
);
}
/// generated route for
/// [FilterImagePage]
class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
@@ -1815,22 +1831,6 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [ImmichUIShowcasePage]
class ImmichUIShowcaseRoute extends PageRouteInfo<void> {
const ImmichUIShowcaseRoute({List<PageRouteInfo>? children})
: super(ImmichUIShowcaseRoute.name, initialChildren: children);
static const String name = 'ImmichUIShowcaseRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const ImmichUIShowcasePage();
},
);
}
/// generated route for
/// [LibraryPage]
class LibraryRoute extends PageRouteInfo<void> {

View File

@@ -1,5 +1,11 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -11,6 +17,50 @@ class OAuthService {
final log = Logger('OAuthService');
OAuthService(this._apiService);
String _generateRandomString(int length) {
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
}
List<int> _randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}
/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String _randomCodeVerifier() {
return base64Url.encode(_randomBytes(42));
}
String _generatePKCECodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Initiates OAuth login flow.
/// Returns the OAuth server URL to redirect to, along with PKCE parameters.
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) async {
final state = _generateRandomString(32);
final codeVerifier = _randomCodeVerifier();
final codeChallenge = _generatePKCECodeChallenge(codeVerifier);
final oAuthServerUrl = await getOAuthServerUrl(sanitizeUrl(serverUrl), state, codeChallenge);
if (oAuthServerUrl == null) {
return null;
}
return OAuthLoginData(serverUrl: oAuthServerUrl, state: state, codeVerifier: codeVerifier);
}
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
return oAuthLogin(oAuthData.serverUrl, oAuthData.state, oAuthData.codeVerifier);
}
Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
// Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);

View File

@@ -1,18 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -28,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext {
final BaseAsset asset;
@@ -174,98 +164,3 @@ class ActionButtonBuilder {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
}
class ViewerKebabMenuButtonContext {
final BaseAsset asset;
final bool isOwner;
final bool isCasting;
final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme;
const ViewerKebabMenuButtonContext({
required this.asset,
required this.isOwner,
required this.isCasting,
required this.timelineOrigin,
this.originalTheme,
});
}
enum ViewerKebabMenuButtonType {
openInfo,
viewInTimeline,
cast,
download;
/// Defines which group each button belongs to.
/// Buttons in the same group will be displayed together,
/// with dividers separating different groups.
int get group => switch (this) {
ViewerKebabMenuButtonType.openInfo => 0,
ViewerKebabMenuButtonType.viewInTimeline => 1,
ViewerKebabMenuButtonType.cast => 1,
ViewerKebabMenuButtonType.download => 1,
};
bool shouldShow(ViewerKebabMenuButtonContext context) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => true,
ViewerKebabMenuButtonType.viewInTimeline =>
context.timelineOrigin != TimelineOrigin.main &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.timelineOrigin != TimelineOrigin.trash &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
};
}
ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => BaseActionButton(
label: 'info'.tr(),
iconData: Icons.info_outline,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
),
ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.t(context: buildContext),
iconData: Icons.image_search,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () async {
await buildContext.maybePop();
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
};
}
}
class ViewerKebabMenuButtonBuilder {
static List<Widget> build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList();
if (visibleButtons.isEmpty) return [];
final List<Widget> result = [];
int? lastGroup;
for (final type in visibleButtons) {
if (lastGroup != null && type.group != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext).build(buildContext, ref));
lastGroup = type.group;
}
return result;
}
}

View File

@@ -81,7 +81,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
if (version < 19 && Store.isBetaTimelineEnabled) {
if (!await _populateLocalAssetTime(drift)) {
if (!await _populateUpdatedAtTime(drift)) {
return;
}
}
@@ -229,7 +229,7 @@ Future<void> _migrateDeviceAsset(Isar db) async {
});
}
Future<bool> _populateLocalAssetTime(Drift db) async {
Future<bool> _populateUpdatedAtTime(Drift db) async {
try {
final nativeApi = NativeSyncApi();
final albums = await nativeApi.getAlbums();
@@ -240,9 +240,6 @@ Future<bool> _populateLocalAssetTime(Drift db) async {
batch.update(
db.localAssetEntity,
LocalAssetEntityCompanion(
longitude: Value(asset.longitude),
latitude: Value(asset.latitude),
adjustmentTime: Value(tryFromSecondsSinceEpoch(asset.adjustmentTime, isUtc: true)),
updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()),
),
where: (t) => t.id.equals(asset.id),
@@ -253,7 +250,7 @@ Future<bool> _populateLocalAssetTime(Drift db) async {
return true;
} catch (error) {
dPrint(() => "[MIGRATION] Error while populating asset time: $error");
dPrint(() => "[MIGRATION] Error while populating updatedAt time: $error");
return false;
}
}

View File

@@ -53,18 +53,16 @@ class ServerUpdateNotification extends HookConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
serverInfoState.versionStatus.message,
textAlign: TextAlign.start,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
),
Text(
serverInfoState.versionStatus.message,
textAlign: TextAlign.start,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
),
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate ||
serverInfoState.versionStatus == VersionStatus.clientOutOfDate) ...[
const SizedBox(width: 8),
const Spacer(),
TextButton(
onPressed: openUpdateLink,
style: TextButton.styleFrom(

View File

@@ -155,8 +155,8 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if (kDebugMode || kProfileMode)
IconButton(
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
),
if (isCasting)
Padding(

View File

@@ -74,8 +74,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
IconButton(
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
),
if (showUploadButton && !isReadonlyModeEnabled)
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),

View File

@@ -1,14 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class LoginButton extends ConsumerWidget {
final Function() onPressed;
class LoginButton extends StatelessWidget {
final VoidCallback onPressed;
const LoginButton({super.key, required this.onPressed});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
onPressed: onPressed,

View File

@@ -0,0 +1,95 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/version_compatibility_warning.dart';
class LoginCredentialsForm extends StatelessWidget {
final TextEditingController emailController;
final TextEditingController passwordController;
final TextEditingController serverEndpointController;
final FocusNode emailFocusNode;
final FocusNode passwordFocusNode;
final bool isLoading;
final bool isOAuthEnabled;
final bool isPasswordLoginEnabled;
final String oAuthButtonLabel;
final String? warningMessage;
final VoidCallback onLogin;
final VoidCallback onOAuthLogin;
final VoidCallback onBack;
const LoginCredentialsForm({
super.key,
required this.emailController,
required this.passwordController,
required this.serverEndpointController,
required this.emailFocusNode,
required this.passwordFocusNode,
required this.isLoading,
required this.isOAuthEnabled,
required this.isPasswordLoginEnabled,
required this.oAuthButtonLabel,
required this.warningMessage,
required this.onLogin,
required this.onOAuthLogin,
required this.onBack,
});
@override
Widget build(BuildContext context) {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (warningMessage != null) VersionCompatibilityWarning(message: warningMessage!),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
if (isPasswordLoginEnabled) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: onLogin),
],
isLoading
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnabled) LoginButton(onPressed: onLogin),
if (isOAuthEnabled) ...[
if (isPasswordLoginEnabled)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel,
onPressed: onOAuthLogin,
),
],
],
),
if (!isOAuthEnabled && !isPasswordLoginEnabled) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(icon: const Icon(Icons.arrow_back), onPressed: onBack, label: const Text('back').tr()),
],
),
);
}
}

View File

@@ -1,14 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:crypto/crypto.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -29,492 +25,382 @@ import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
import 'package:immich_mobile/widgets/forms/login/login_credentials_form.dart';
import 'package:immich_mobile/widgets/forms/login/server_selection_form.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class LoginForm extends HookConsumerWidget {
LoginForm({super.key});
final log = Logger('LoginForm');
class LoginForm extends ConsumerStatefulWidget {
const LoginForm({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode();
final serverEndpointFocusNode = useFocusNode();
final isLoading = useState<bool>(false);
final isLoadingServer = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final isPasswordLoginEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
final logoAnimationController = useAnimationController(duration: const Duration(seconds: 60))..repeat();
final serverInfo = ref.watch(serverInfoProvider);
final warningMessage = useState<String?>(null);
final loginFormKey = GlobalKey<FormState>();
final ValueNotifier<String?> serverEndpoint = useState<String?>(null);
ConsumerState<LoginForm> createState() => _LoginFormState();
}
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
class _LoginFormState extends ConsumerState<LoginForm> with SingleTickerProviderStateMixin {
final _log = Logger('LoginForm');
final _loginFormKey = GlobalKey<FormState>();
warningMessage.value = getVersionCompatibilityMessage(
late final TextEditingController _emailController;
late final TextEditingController _passwordController;
late final TextEditingController _serverEndpointController;
late final FocusNode _emailFocusNode;
late final FocusNode _passwordFocusNode;
late final FocusNode _serverEndpointFocusNode;
late final AnimationController _logoAnimationController;
bool _isLoading = false;
bool _isLoadingServer = false;
bool _isOAuthEnabled = false;
bool _isPasswordLoginEnabled = false;
String _oAuthButtonLabel = 'OAuth';
String? _serverEndpoint;
String? _warningMessage;
@override
void initState() {
super.initState();
_emailController = TextEditingController();
_passwordController = TextEditingController();
_serverEndpointController = TextEditingController();
_emailFocusNode = FocusNode();
_passwordFocusNode = FocusNode();
_serverEndpointFocusNode = FocusNode();
_logoAnimationController = AnimationController(vsync: this, duration: const Duration(seconds: 60))..repeat();
// Load saved server URL if available
WidgetsBinding.instance.addPostFrameCallback((_) {
final serverUrl = getServerUrl();
if (serverUrl != null) {
_serverEndpointController.text = serverUrl;
}
});
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_serverEndpointController.dispose();
_emailFocusNode.dispose();
_passwordFocusNode.dispose();
_serverEndpointFocusNode.dispose();
_logoAnimationController.dispose();
super.dispose();
}
Future<void> _checkVersionMismatch() async {
try {
final serverInfo = ref.read(serverInfoProvider);
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
setState(() {
_warningMessage = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
});
} catch (error) {
setState(() {
_warningMessage = 'Error checking version compatibility';
});
}
}
Future<void> _getServerAuthSettings() async {
final serverUrl = _serverEndpointController.text;
if (serverUrl.isEmpty) {
ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error);
return;
}
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async {
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
try {
setState(() {
_isLoadingServer = true;
});
// Guard empty URL
if (serverUrl.isEmpty) {
ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error);
}
try {
isLoadingServer.value = true;
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
// Fetch and load server config and features
await ref.read(serverInfoProvider.notifier).getServerInfo();
final serverInfo = ref.read(serverInfoProvider);
final features = serverInfo.serverFeatures;
final config = serverInfo.serverConfig;
isOauthEnable.value = features.oauthEnabled;
isPasswordLoginEnable.value = features.passwordLogin;
oAuthButtonLabel.value = config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth';
serverEndpoint.value = endpoint;
} on ApiException catch (e) {
ImmichToast.show(
context: context,
msg: e.message ?? 'login_form_api_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} on HandshakeException {
ImmichToast.show(
context: context,
msg: 'login_form_handshake_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} catch (e) {
final settings = await ref.read(authProvider.notifier).getServerAuthSettings(serverUrl);
if (settings == null) {
ImmichToast.show(
context: context,
msg: 'login_form_server_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
}
isLoadingServer.value = false;
}
useEffect(() {
final serverUrl = getServerUrl();
if (serverUrl != null) {
serverEndpointController.text = serverUrl;
}
return null;
}, []);
populateTestLoginInfo() {
emailController.text = 'demo@immich.app';
passwordController.text = 'demo';
serverEndpointController.text = 'https://demo.immich.app';
}
populateTestLoginInfo1() {
emailController.text = 'testuser@email.com';
passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:2283/api';
}
Future<void> handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await backgroundManager.hashAssets();
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
}
getManageMediaPermission() async {
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'manage_media_access_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
const SizedBox(height: 4),
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'cancel'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
TextButton(
onPressed: () {
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
'manage_media_access_settings'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
],
);
},
);
}
}
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
login() async {
TextInput.finishAutofillContext();
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final result = await ref.read(authProvider.notifier).login(emailController.text, passwordController.text);
if (result.shouldChangePassword && !result.isAdmin) {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error) {
ImmichToast.show(
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
}
String generateRandomString(int length) {
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
}
List<int> randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}
/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String randomCodeVerifier() {
return base64Url.encode(randomBytes(42));
}
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
var bytes = utf8.encode(codeVerifier);
var digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
oAuthLogin() async {
var oAuthService = ref.watch(oAuthServiceProvider);
String? oAuthServerUrl;
final state = generateRandomString(32);
final codeVerifier = randomCodeVerifier();
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
try {
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
sanitizeUrl(serverEndpointController.text),
state,
codeChallenge,
);
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
} catch (error, stack) {
log.severe('Error getting OAuth server Url: $error', stack);
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_config".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
_resetServerState();
return;
}
if (oAuthServerUrl != null) {
try {
final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier);
setState(() {
_isOAuthEnabled = settings.isOAuthEnabled;
_isPasswordLoginEnabled = settings.isPasswordLoginEnabled;
_oAuthButtonLabel = settings.oAuthButtonText;
_serverEndpoint = settings.endpoint;
_isLoadingServer = false;
});
if (loginResponseDto == null) {
return;
}
await _checkVersionMismatch();
} on ApiException catch (e) {
ImmichToast.show(
context: context,
msg: e.message ?? 'login_form_api_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
} on HandshakeException {
ImmichToast.show(
context: context,
msg: 'login_form_handshake_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
} catch (e) {
ImmichToast.show(
context: context,
msg: 'login_form_server_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
}
}
log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
void _resetServerState() {
setState(() {
_isOAuthEnabled = false;
_isPasswordLoginEnabled = true;
_isLoadingServer = false;
});
}
final isSuccess = await ref
.watch(authProvider.notifier)
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
void _populateTestLoginInfo() {
_emailController.text = 'demo@immich.app';
_passwordController.text = 'demo';
_serverEndpointController.text = 'https://demo.immich.app';
}
if (isSuccess) {
isLoading.value = false;
final permission = ref.watch(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
unawaited(ref.watch(backupProvider.notifier).resumeBackup());
}
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error, stack) {
log.severe('Error logging in with OAuth: $error', stack);
void _populateTestLoginInfo1() {
_emailController.text = 'testuser@email.com';
_passwordController.text = 'password';
_serverEndpointController.text = 'http://10.1.15.216:2283/api';
}
ImmichToast.show(
context: context,
msg: error.toString(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
Future<void> _handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await backgroundManager.hashAssets();
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
}
Future<void> _getManageMediaPermission() async {
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'manage_media_access_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
const SizedBox(height: 4),
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'cancel'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
TextButton(
onPressed: () {
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
'manage_media_access_settings'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
],
);
} finally {
isLoading.value = false;
}
},
);
}
}
bool _isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
Future<void> _login() async {
TextInput.finishAutofillContext();
setState(() {
_isLoading = true;
});
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final result = await ref.read(authProvider.notifier).login(_emailController.text, _passwordController.text);
if (result.shouldChangePassword && !result.isAdmin) {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (_isSyncRemoteDeletionsMode()) {
await _getManageMediaPermission();
}
unawaited(_handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error) {
ImmichToast.show(
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _oAuthLogin() async {
setState(() {
_isLoading = true;
});
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final oAuthData = await ref
.read(oAuthProvider.notifier)
.getOAuthLoginData(sanitizeUrl(_serverEndpointController.text));
if (oAuthData == null) {
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_disable".tr(),
toastType: ToastType.info,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
setState(() {
_isLoading = false;
});
return;
}
}
buildSelectServer() {
const buttonRadius = 25.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: getServerAuthSettings,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(buttonRadius),
bottomLeft: Radius.circular(buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(buttonRadius),
bottomRight: Radius.circular(buttonRadius),
),
),
),
onPressed: isLoadingServer.value ? null : getServerAuthSettings,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoadingServer.value) const LoadingIcon(),
],
);
}
final loginResponseDto = await ref.read(oAuthProvider.notifier).completeOAuthLogin(oAuthData);
buildVersionCompatWarning() {
checkVersionMismatch();
if (warningMessage.value == null) {
return const SizedBox.shrink();
if (loginResponseDto == null) {
setState(() {
_isLoading = false;
});
return;
}
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
),
child: Text(warningMessage.value!, textAlign: TextAlign.center),
),
);
_log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
final isSuccess = await ref.read(authProvider.notifier).saveAuthInfo(accessToken: loginResponseDto.accessToken);
if (isSuccess) {
setState(() {
_isLoading = false;
});
final permission = ref.read(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
unawaited(ref.read(backupProvider.notifier).resumeBackup());
}
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (_isSyncRemoteDeletionsMode()) {
await _getManageMediaPermission();
}
unawaited(_handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error, stack) {
_log.severe('Error logging in with OAuth: $error', stack);
ImmichToast.show(context: context, msg: error.toString(), toastType: ToastType.error, gravity: ToastGravity.TOP);
} finally {
setState(() {
_isLoading = false;
});
}
}
buildLogin() {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildVersionCompatWarning(),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
if (isPasswordLoginEnable.value) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login),
],
void _goBack() {
setState(() {
_serverEndpoint = null;
});
}
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnable.value) LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
if (isPasswordLoginEnable.value)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
),
],
],
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('back').tr(),
),
],
),
);
}
final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin();
@override
Widget build(BuildContext context) {
final serverSelectionOrLogin = _serverEndpoint == null
? ServerSelectionForm(
serverEndpointController: _serverEndpointController,
serverEndpointFocusNode: _serverEndpointFocusNode,
isLoading: _isLoadingServer,
onSubmit: _getServerAuthSettings,
)
: LoginCredentialsForm(
emailController: _emailController,
passwordController: _passwordController,
serverEndpointController: _serverEndpointController,
emailFocusNode: _emailFocusNode,
passwordFocusNode: _passwordFocusNode,
isLoading: _isLoading,
isOAuthEnabled: _isOAuthEnabled,
isPasswordLoginEnabled: _isPasswordLoginEnabled,
oAuthButtonLabel: _oAuthButtonLabel,
warningMessage: _warningMessage,
onLogin: _login,
onOAuthLogin: _oAuthLogin,
onBack: _goBack,
);
return LayoutBuilder(
builder: (context, constraints) {
@@ -532,20 +418,19 @@ class LoginForm extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.end,
children: [
GestureDetector(
onDoubleTap: () => populateTestLoginInfo(),
onLongPress: () => populateTestLoginInfo1(),
onDoubleTap: _populateTestLoginInfo,
onLongPress: _populateTestLoginInfo1,
child: RotationTransition(
turns: logoAnimationController,
turns: _logoAnimationController,
child: const ImmichLogo(heroTag: 'logo'),
),
),
const Padding(padding: EdgeInsets.only(top: 8.0, bottom: 16), child: ImmichTitleText()),
],
),
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
Form(key: loginFormKey, child: serverSelectionOrLogin),
Form(key: _loginFormKey, child: serverSelectionOrLogin),
],
),
),

View File

@@ -1,23 +1,20 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class OAuthLoginButton extends ConsumerWidget {
class OAuthLoginButton extends StatelessWidget {
final TextEditingController serverEndpointController;
final ValueNotifier<bool> isLoading;
final String buttonLabel;
final Function() onPressed;
final VoidCallback onPressed;
const OAuthLoginButton({
super.key,
required this.serverEndpointController,
required this.isLoading,
required this.buttonLabel,
required this.onPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: context.primaryColor.withAlpha(230),

View File

@@ -1,36 +1,45 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class PasswordInput extends HookConsumerWidget {
class PasswordInput extends StatefulWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final Function()? onSubmit;
final VoidCallback? onSubmit;
const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPasswordVisible = useState<bool>(false);
State<PasswordInput> createState() => _PasswordInputState();
}
class _PasswordInputState extends State<PasswordInput> {
bool _isPasswordVisible = false;
void _togglePasswordVisibility() {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
}
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: !isPasswordVisible.value,
controller: controller,
obscureText: !_isPasswordVisible,
controller: widget.controller,
decoration: InputDecoration(
labelText: 'password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
suffixIcon: IconButton(
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp),
onPressed: _togglePasswordVisibility,
icon: Icon(_isPasswordVisible ? Icons.visibility_off_sharp : Icons.visibility_sharp),
),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
onFieldSubmitted: (_) => widget.onSubmit?.call(),
focusNode: widget.focusNode,
textInputAction: TextInputAction.go,
);
}

View File

@@ -0,0 +1,78 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
class ServerSelectionForm extends StatelessWidget {
final TextEditingController serverEndpointController;
final FocusNode serverEndpointFocusNode;
final bool isLoading;
final VoidCallback onSubmit;
const ServerSelectionForm({
super.key,
required this.serverEndpointController,
required this.serverEndpointFocusNode,
required this.isLoading,
required this.onSubmit,
});
static const double _buttonRadius = 25.0;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: onSubmit,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(_buttonRadius),
bottomLeft: Radius.circular(_buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(_buttonRadius),
bottomRight: Radius.circular(_buttonRadius),
),
),
),
onPressed: isLoading ? null : onSubmit,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoading) const LoadingIcon(),
],
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class VersionCompatibilityWarning extends StatelessWidget {
final String message;
const VersionCompatibilityWarning({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
),
child: Text(message, textAlign: TextAlign.center),
),
);
}
}

View File

@@ -2,27 +2,26 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class EntityCountTile extends StatelessWidget {
class EntitiyCountTile extends StatelessWidget {
final int count;
final String label;
final IconData icon;
const EntityCountTile({super.key, required this.count, required this.label, required this.icon});
const EntitiyCountTile({super.key, required this.count, required this.label, required this.icon});
String zeroPadding(int number, int targetWidth) {
final numStr = number.toString();
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
}
int calculateMaxDigits(double availableWidth) {
const double charWidth = 11.0;
return (availableWidth / charWidth).floor().clamp(1, 8);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final availableWidth = (screenWidth - 32 - 8) / 2;
const double charWidth = 11.0;
final maxDigits = ((availableWidth - 32) / charWidth).floor().clamp(1, 8);
return Container(
height: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
@@ -30,6 +29,7 @@ class EntityCountTile extends StatelessWidget {
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon and Label
@@ -38,30 +38,33 @@ class EntityCountTile extends StatelessWidget {
children: [
Icon(icon, color: context.primaryColor),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
Text(
label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const SizedBox(height: 12),
// Number
const Spacer(),
RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
LayoutBuilder(
builder: (context, constraints) {
final maxDigits = calculateMaxDigits(constraints.maxWidth);
return RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
),
TextSpan(
text: count.toString(),
style: TextStyle(color: context.primaryColor),
),
],
),
TextSpan(
text: count.toString(),
style: TextStyle(color: context.primaryColor),
),
],
),
);
},
),
],
),

View File

@@ -282,87 +282,76 @@ class _SyncStatsCounts extends ConsumerWidget {
_SectionHeaderText(text: "assets".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
// 1. Wrap in IntrinsicHeight
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// 2. Stretch children vertically to fill the IntrinsicHeight
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: localAssetCount,
icon: Icons.smartphone,
),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: localAssetCount,
icon: Icons.smartphone,
),
Expanded(
child: EntityCountTile(
label: "remote".t(context: context),
count: remoteAssetCount,
icon: Icons.cloud,
),
),
Expanded(
child: EntitiyCountTile(
label: "remote".t(context: context),
count: remoteAssetCount,
icon: Icons.cloud,
),
],
),
),
],
),
),
_SectionHeaderText(text: "albums".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: localAlbumCount,
icon: Icons.smartphone,
),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: localAlbumCount,
icon: Icons.smartphone,
),
Expanded(
child: EntityCountTile(
label: "remote".t(context: context),
count: remoteAlbumCount,
icon: Icons.cloud,
),
),
Expanded(
child: EntitiyCountTile(
label: "remote".t(context: context),
count: remoteAlbumCount,
icon: Icons.cloud,
),
],
),
),
],
),
),
_SectionHeaderText(text: "other".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "memories".t(context: context),
count: memoryCount,
icon: Icons.calendar_today,
),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "memories".t(context: context),
count: memoryCount,
icon: Icons.calendar_today,
),
Expanded(
child: EntityCountTile(
label: "hashed_assets".t(context: context),
count: localHashedCount,
icon: Icons.tag,
),
),
Expanded(
child: EntitiyCountTile(
label: "hashed_assets".t(context: context),
count: localHashedCount,
icon: Icons.tag,
),
],
),
),
],
),
),
// To be removed once the experimental feature is stable
@@ -375,29 +364,26 @@ class _SyncStatsCounts extends ConsumerWidget {
return counts.when(
data: (c) => Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: c.total,
icon: Icons.delete_outline,
),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: c.total,
icon: Icons.delete_outline,
),
Expanded(
child: EntityCountTile(
label: "hashed_assets".t(context: context),
count: c.hashed,
icon: Icons.tag,
),
),
Expanded(
child: EntitiyCountTile(
label: "hashed_assets".t(context: context),
count: c.hashed,
icon: Icons.tag,
),
],
),
),
],
),
),
loading: () => const CircularProgressIndicator(),

View File

@@ -14,7 +14,7 @@ class WorkflowActionItemDto {
/// Returns a new [WorkflowActionItemDto] instance.
WorkflowActionItemDto({
this.actionConfig,
required this.pluginActionId,
required this.actionId,
});
///
@@ -25,21 +25,21 @@ class WorkflowActionItemDto {
///
Object? actionConfig;
String pluginActionId;
String actionId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto &&
other.actionConfig == actionConfig &&
other.pluginActionId == pluginActionId;
other.actionId == actionId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actionConfig == null ? 0 : actionConfig!.hashCode) +
(pluginActionId.hashCode);
(actionId.hashCode);
@override
String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, pluginActionId=$pluginActionId]';
String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, actionId=$actionId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -48,7 +48,7 @@ class WorkflowActionItemDto {
} else {
// json[r'actionConfig'] = null;
}
json[r'pluginActionId'] = this.pluginActionId;
json[r'actionId'] = this.actionId;
return json;
}
@@ -62,7 +62,7 @@ class WorkflowActionItemDto {
return WorkflowActionItemDto(
actionConfig: mapValueOfType<Object>(json, r'actionConfig'),
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
actionId: mapValueOfType<String>(json, r'actionId')!,
);
}
return null;
@@ -110,7 +110,7 @@ class WorkflowActionItemDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'pluginActionId',
'actionId',
};
}

View File

@@ -14,41 +14,41 @@ class WorkflowActionResponseDto {
/// Returns a new [WorkflowActionResponseDto] instance.
WorkflowActionResponseDto({
required this.actionConfig,
required this.actionId,
required this.id,
required this.order,
required this.pluginActionId,
required this.workflowId,
});
Object? actionConfig;
String actionId;
String id;
num order;
String pluginActionId;
String workflowId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto &&
other.actionConfig == actionConfig &&
other.actionId == actionId &&
other.id == id &&
other.order == order &&
other.pluginActionId == pluginActionId &&
other.workflowId == workflowId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actionConfig == null ? 0 : actionConfig!.hashCode) +
(actionId.hashCode) +
(id.hashCode) +
(order.hashCode) +
(pluginActionId.hashCode) +
(workflowId.hashCode);
@override
String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, id=$id, order=$order, pluginActionId=$pluginActionId, workflowId=$workflowId]';
String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, actionId=$actionId, id=$id, order=$order, workflowId=$workflowId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -57,9 +57,9 @@ class WorkflowActionResponseDto {
} else {
// json[r'actionConfig'] = null;
}
json[r'actionId'] = this.actionId;
json[r'id'] = this.id;
json[r'order'] = this.order;
json[r'pluginActionId'] = this.pluginActionId;
json[r'workflowId'] = this.workflowId;
return json;
}
@@ -74,9 +74,9 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto(
actionConfig: mapValueOfType<Object>(json, r'actionConfig'),
actionId: mapValueOfType<String>(json, r'actionId')!,
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
}
@@ -126,9 +126,9 @@ class WorkflowActionResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actionConfig',
'actionId',
'id',
'order',
'pluginActionId',
'workflowId',
};
}

View File

@@ -14,7 +14,7 @@ class WorkflowFilterItemDto {
/// Returns a new [WorkflowFilterItemDto] instance.
WorkflowFilterItemDto({
this.filterConfig,
required this.pluginFilterId,
required this.filterId,
});
///
@@ -25,21 +25,21 @@ class WorkflowFilterItemDto {
///
Object? filterConfig;
String pluginFilterId;
String filterId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto &&
other.filterConfig == filterConfig &&
other.pluginFilterId == pluginFilterId;
other.filterId == filterId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filterConfig == null ? 0 : filterConfig!.hashCode) +
(pluginFilterId.hashCode);
(filterId.hashCode);
@override
String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, pluginFilterId=$pluginFilterId]';
String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, filterId=$filterId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -48,7 +48,7 @@ class WorkflowFilterItemDto {
} else {
// json[r'filterConfig'] = null;
}
json[r'pluginFilterId'] = this.pluginFilterId;
json[r'filterId'] = this.filterId;
return json;
}
@@ -62,7 +62,7 @@ class WorkflowFilterItemDto {
return WorkflowFilterItemDto(
filterConfig: mapValueOfType<Object>(json, r'filterConfig'),
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
filterId: mapValueOfType<String>(json, r'filterId')!,
);
}
return null;
@@ -110,7 +110,7 @@ class WorkflowFilterItemDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'pluginFilterId',
'filterId',
};
}

View File

@@ -14,41 +14,41 @@ class WorkflowFilterResponseDto {
/// Returns a new [WorkflowFilterResponseDto] instance.
WorkflowFilterResponseDto({
required this.filterConfig,
required this.filterId,
required this.id,
required this.order,
required this.pluginFilterId,
required this.workflowId,
});
Object? filterConfig;
String filterId;
String id;
num order;
String pluginFilterId;
String workflowId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto &&
other.filterConfig == filterConfig &&
other.filterId == filterId &&
other.id == id &&
other.order == order &&
other.pluginFilterId == pluginFilterId &&
other.workflowId == workflowId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filterConfig == null ? 0 : filterConfig!.hashCode) +
(filterId.hashCode) +
(id.hashCode) +
(order.hashCode) +
(pluginFilterId.hashCode) +
(workflowId.hashCode);
@override
String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, id=$id, order=$order, pluginFilterId=$pluginFilterId, workflowId=$workflowId]';
String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, filterId=$filterId, id=$id, order=$order, workflowId=$workflowId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -57,9 +57,9 @@ class WorkflowFilterResponseDto {
} else {
// json[r'filterConfig'] = null;
}
json[r'filterId'] = this.filterId;
json[r'id'] = this.id;
json[r'order'] = this.order;
json[r'pluginFilterId'] = this.pluginFilterId;
json[r'workflowId'] = this.workflowId;
return json;
}
@@ -74,9 +74,9 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto(
filterConfig: mapValueOfType<Object>(json, r'filterConfig'),
filterId: mapValueOfType<String>(json, r'filterId')!,
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
}
@@ -126,9 +126,9 @@ class WorkflowFilterResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'filterConfig',
'filterId',
'id',
'order',
'pluginFilterId',
'workflowId',
};
}

View File

@@ -1,3 +0,0 @@
export 'src/buttons/close_button.dart';
export 'src/buttons/icon_button.dart';
export 'src/types.dart';

View File

@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/buttons/icon_button.dart';
import 'package:immich_ui/src/types.dart';
class ImmichCloseButton extends StatelessWidget {
final VoidCallback? onTap;
final ImmichVariant variant;
final ImmichColor color;
const ImmichCloseButton({
super.key,
this.onTap,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.ghost,
});
@override
Widget build(BuildContext context) => ImmichIconButton(
key: key,
icon: Icons.close,
color: color,
variant: variant,
onTap: onTap ?? () => Navigator.of(context).pop(),
);
}

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