mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 21:42:18 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c9263b69f |
@@ -16,7 +16,7 @@ services:
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
- ../packages/plugins:/build/corePlugin
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
immich-machine-learning:
|
||||
|
||||
@@ -116,6 +116,7 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
~/.android/sdk
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: build-mobile-gradle-${{ runner.os }}-main
|
||||
|
||||
- name: Setup Android SDK
|
||||
@@ -188,6 +189,7 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
~/.android/sdk
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
|
||||
|
||||
build-sign-ios:
|
||||
|
||||
+15
-11
@@ -62,6 +62,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
@@ -81,7 +84,7 @@ jobs:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-unit
|
||||
run: mise run //server:ci-unit
|
||||
run: mise run ci-unit
|
||||
|
||||
cli-unit-tests:
|
||||
name: Unit Test CLI
|
||||
@@ -377,7 +380,7 @@ jobs:
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Setup packages
|
||||
run: pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && pnpm --filter @immich/sdk --filter @immich/cli build
|
||||
run: pnpm --filter "@immich/*" install --frozen-lockfile && pnpm --filter "@immich/*" build
|
||||
|
||||
- name: Run setup web
|
||||
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
|
||||
@@ -562,7 +565,7 @@ jobs:
|
||||
run: mise //mobile:codegen:translation
|
||||
|
||||
- name: Run tests
|
||||
run: mise //mobile:test
|
||||
run: mise //mobile:test -j 1
|
||||
|
||||
ml-unit-tests:
|
||||
name: Unit Test ML
|
||||
@@ -675,6 +678,7 @@ jobs:
|
||||
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
|
||||
|
||||
- name: Run API generation
|
||||
run: mise //:open-api
|
||||
working-directory: open-api
|
||||
@@ -713,6 +717,9 @@ jobs:
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
@@ -734,21 +741,18 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build plugins
|
||||
run: mise //:plugins
|
||||
|
||||
- name: Build the app
|
||||
run: mise //server:build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run existing migrations
|
||||
run: pnpm --filter immich migrations:run
|
||||
run: pnpm migrations:run
|
||||
|
||||
- name: Test npm run schema:reset command works
|
||||
run: pnpm --filter immich schema:reset
|
||||
run: pnpm schema:reset
|
||||
|
||||
- name: Generate new migrations
|
||||
continue-on-error: true
|
||||
run: pnpm --filter migrations:generate src/TestMigration
|
||||
run: pnpm migrations:generate src/TestMigration
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
@@ -764,7 +768,7 @@ jobs:
|
||||
run: |
|
||||
echo "ERROR: Generated migration files not up to date!"
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
cat ./server/src/*-TestMigration.ts
|
||||
cat ./src/*-TestMigration.ts
|
||||
exit 1
|
||||
|
||||
- name: Run SQL generation
|
||||
|
||||
+30
-30
@@ -2,37 +2,37 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
version = "4.52.5"
|
||||
constraints = "4.52.5"
|
||||
hashes = [
|
||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
||||
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
|
||||
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
|
||||
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
|
||||
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
|
||||
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
|
||||
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
|
||||
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
|
||||
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
|
||||
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
|
||||
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
|
||||
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
|
||||
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
|
||||
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
|
||||
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
|
||||
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
|
||||
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
|
||||
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
|
||||
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
|
||||
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
|
||||
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
|
||||
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
|
||||
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
|
||||
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
|
||||
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
|
||||
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
|
||||
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
|
||||
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.7"
|
||||
version = "4.52.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-30
@@ -2,37 +2,37 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
version = "4.52.5"
|
||||
constraints = "4.52.5"
|
||||
hashes = [
|
||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
||||
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
|
||||
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
|
||||
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
|
||||
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
|
||||
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
|
||||
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
|
||||
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
|
||||
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
|
||||
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
|
||||
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
|
||||
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
|
||||
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
|
||||
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
|
||||
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
|
||||
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
|
||||
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
|
||||
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
|
||||
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
|
||||
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
|
||||
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
|
||||
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
|
||||
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
|
||||
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
|
||||
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
|
||||
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
|
||||
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
|
||||
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.7"
|
||||
version = "4.52.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
- ../packages/plugins:/build/corePlugin
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -13,11 +13,8 @@ The `immich-server` docker image comes preinstalled with an administrative CLI (
|
||||
| `enable-oauth-login` | Enable OAuth login |
|
||||
| `disable-oauth-login` | Disable OAuth login |
|
||||
| `list-users` | List Immich users |
|
||||
| `grant-admin` | Grant admin privileges to a user (by email) |
|
||||
| `revoke-admin` | Revoke admin privileges from a user (by email) |
|
||||
| `version` | Print Immich version |
|
||||
| `change-media-location` | Change database file paths to align with a new media location |
|
||||
| `schema-check` | Verify database migrations and check for schema drift |
|
||||
|
||||
## How to run a command
|
||||
|
||||
@@ -105,22 +102,6 @@ immich-admin list-users
|
||||
]
|
||||
```
|
||||
|
||||
Grant Admin
|
||||
|
||||
```
|
||||
immich-admin grant-admin
|
||||
? Please enter the user email: user@example.com
|
||||
Admin access has been granted to user@example.com
|
||||
```
|
||||
|
||||
Revoke Admin
|
||||
|
||||
```
|
||||
immich-admin revoke-admin
|
||||
? Please enter the user email: user@example.com
|
||||
Admin access has been revoked from user@example.com
|
||||
```
|
||||
|
||||
Print Immich Version
|
||||
|
||||
```
|
||||
@@ -145,12 +126,3 @@ immich-admin change-media-location
|
||||
Database file paths updated successfully! 🎉
|
||||
...
|
||||
```
|
||||
|
||||
Schema Check
|
||||
|
||||
```
|
||||
immich-admin schema-check
|
||||
Migrations are up to date
|
||||
|
||||
No schema drift detected
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@ make e2e
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
- `pnpm install`
|
||||
- `pnpm --filter @immich/sdk --filter @immich/cli build`
|
||||
- `pnpm --filter "@immich/*" build`
|
||||
- `mise //:open-api`
|
||||
|
||||
Once the test environment is running, the e2e tests can be run via:
|
||||
|
||||
@@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list
|
||||
|
||||
## Step 4 - Configure Firewall Settings
|
||||
|
||||
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers.
|
||||
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS.
|
||||
|
||||
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
|
||||
|
||||
@@ -74,7 +74,6 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
|
||||
|
||||
<details>
|
||||
<summary>Updating Immich using Container Manager</summary>
|
||||
|
||||
Check the post installation and upgrade instructions at the links above before proceeding with this section.
|
||||
|
||||
## Step 1. Backup
|
||||
@@ -111,7 +110,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
|
||||
|
||||
## Step 5. Update firewall rule
|
||||
|
||||
Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
|
||||
The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
|
||||
|
||||
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
|
||||

|
||||
@@ -124,67 +123,4 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi
|
||||
|
||||

|
||||
|
||||
To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions.
|
||||
|
||||
</details>
|
||||
|
||||
<details id="set-fixed-subnet">
|
||||
<summary>Set Fixed Subnet</summary>
|
||||
|
||||
Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`:
|
||||
|
||||
## Step 1. Determine current subnet
|
||||
|
||||
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
|
||||

|
||||
|
||||
## Step 2. Add network configuration
|
||||
|
||||
Add the following network configuration at the end of your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
immich-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
```
|
||||
|
||||
If your docker container is running on a different subnet then update accordingly.
|
||||
|
||||
## Step 3. Add network to each service
|
||||
|
||||
Add the network to each service (immich-server, immich-machine-learning, redis, database):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
immich-server:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
immich-machine-learning:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
redis:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
database:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
```
|
||||
|
||||
Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers.
|
||||
|
||||
## Step 4. Update Firewall Rules, if necessary
|
||||
|
||||
If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
@@ -13,9 +13,6 @@ describe('/admin/database-backups', () => {
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
|
||||
@@ -568,8 +568,6 @@ export const utils = {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
|
||||
|
||||
return utils.poll(
|
||||
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||
|
||||
+14
-29
@@ -22,12 +22,13 @@
|
||||
"add_birthday": "Add a birthday",
|
||||
"add_endpoint": "Add endpoint",
|
||||
"add_exclusion_pattern": "Add exclusion pattern",
|
||||
"add_filter": "Add filter",
|
||||
"add_filter_description": "Click to add a filter condition",
|
||||
"add_location": "Add location",
|
||||
"add_more_users": "Add more users",
|
||||
"add_partner": "Add partner",
|
||||
"add_path": "Add path",
|
||||
"add_photos": "Add photos",
|
||||
"add_step": "Add step",
|
||||
"add_tag": "Add tag",
|
||||
"add_to": "Add to…",
|
||||
"add_to_album": "Add to album",
|
||||
@@ -41,6 +42,7 @@
|
||||
"add_to_shared_album": "Add to shared album",
|
||||
"add_upload_to_stack": "Add upload to stack",
|
||||
"add_url": "Add URL",
|
||||
"add_workflow_step": "Add workflow step",
|
||||
"added_to_archive": "Added to archive",
|
||||
"added_to_favorites": "Added to favorites",
|
||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||
@@ -691,6 +693,7 @@
|
||||
"backup_settings_subtitle": "Manage upload settings",
|
||||
"backup_upload_details_page_more_details": "Tap for more details",
|
||||
"backward": "Backward",
|
||||
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
|
||||
"biometric_auth_enabled": "Biometric authentication enabled",
|
||||
"biometric_locked_out": "You are locked out of biometric authentication",
|
||||
"biometric_no_options": "No biometric options available",
|
||||
@@ -731,7 +734,6 @@
|
||||
"cannot_update_the_description": "Cannot update the description",
|
||||
"cast": "Cast",
|
||||
"cast_description": "Configure available cast destinations",
|
||||
"change": "Change",
|
||||
"change_date": "Change date",
|
||||
"change_description": "Change description",
|
||||
"change_display_order": "Change display order",
|
||||
@@ -760,7 +762,6 @@
|
||||
"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": "Choose",
|
||||
"choose_matching_people_to_merge": "Choose matching people to merge",
|
||||
"city": "City",
|
||||
"cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
|
||||
@@ -778,7 +779,6 @@
|
||||
"clear": "Clear",
|
||||
"clear_all": "Clear all",
|
||||
"clear_all_recent_searches": "Clear all recent searches",
|
||||
"clear_failed_count": "Clear failed ({count})",
|
||||
"clear_file_cache": "Clear File Cache",
|
||||
"clear_message": "Clear message",
|
||||
"clear_value": "Clear value",
|
||||
@@ -810,7 +810,6 @@
|
||||
"comments_are_disabled": "Comments are disabled",
|
||||
"common_create_new_album": "Create new album",
|
||||
"completed": "Completed",
|
||||
"configuration": "Configuration",
|
||||
"confirm": "Confirm",
|
||||
"confirm_admin_password": "Confirm Admin Password",
|
||||
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
|
||||
@@ -825,7 +824,6 @@
|
||||
"contain": "Contain",
|
||||
"context": "Context",
|
||||
"continue": "Continue",
|
||||
"control_bottom_app_bar_add_tags": "Add Tags",
|
||||
"control_bottom_app_bar_create_new_album": "Create new album",
|
||||
"control_bottom_app_bar_delete_from_immich": "Delete from Immich",
|
||||
"control_bottom_app_bar_delete_from_local": "Delete from device",
|
||||
@@ -888,16 +886,17 @@
|
||||
"cutoff_date_description": "Keep photos from the last…",
|
||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||
"cutoff_year": "{count, plural, one {year} other {years}}",
|
||||
"daily_title_text_date": "E, MMM dd",
|
||||
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||
"dark": "Dark",
|
||||
"dark_theme": "Switch to dark theme",
|
||||
"date": "Date",
|
||||
"date_after": "Date after",
|
||||
"date_and_time": "Date and Time",
|
||||
"date_before": "Date before",
|
||||
"date_of_birth": "Date of birth",
|
||||
"date_format": "E, LLL d, y • h:mm a",
|
||||
"date_of_birth_saved": "Date of birth saved successfully",
|
||||
"date_range": "Date range",
|
||||
"date_time_original": "Date/Time Original",
|
||||
"day": "Day",
|
||||
"days": "Days",
|
||||
"deduplicate_all": "Deduplicate All",
|
||||
@@ -1078,7 +1077,6 @@
|
||||
"failed_to_remove_product_key": "Failed to remove product key",
|
||||
"failed_to_reset_pin_code": "Failed to reset PIN code",
|
||||
"failed_to_stack_assets": "Failed to stack assets",
|
||||
"failed_to_tag_assets": "Failed to tag assets",
|
||||
"failed_to_unstack_assets": "Failed to un-stack assets",
|
||||
"failed_to_update_notification_status": "Failed to update notification status",
|
||||
"incorrect_email_or_password": "Incorrect email or password",
|
||||
@@ -1198,13 +1196,11 @@
|
||||
"export_as_json": "Export as JSON",
|
||||
"export_database": "Export Database",
|
||||
"export_database_description": "Export the SQLite database",
|
||||
"exposure_time": "Exposure Time",
|
||||
"extension": "Extension",
|
||||
"external": "External",
|
||||
"external_libraries": "External Libraries",
|
||||
"external_network": "External network",
|
||||
"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",
|
||||
"f_number": "F-Number",
|
||||
"face_unassigned": "Unassigned",
|
||||
"failed": "Failed",
|
||||
"failed_count": "Failed: {count}",
|
||||
@@ -1222,6 +1218,7 @@
|
||||
"features_setting_description": "Manage the app features",
|
||||
"file_name_or_extension": "File name or extension",
|
||||
"file_name_text": "File name",
|
||||
"file_name_with_value": "File name: {file_name}",
|
||||
"file_size": "File size",
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
@@ -1234,7 +1231,6 @@
|
||||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
"fix_incorrect_match": "Fix incorrect match",
|
||||
"focal_length": "Focal Length",
|
||||
"folder": "Folder",
|
||||
"folder_not_found": "Folder not found",
|
||||
"folders": "Folders",
|
||||
@@ -1355,7 +1351,6 @@
|
||||
"ios_debug_info_no_sync_yet": "No background sync job has run yet",
|
||||
"ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}",
|
||||
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
|
||||
"iso": "ISO",
|
||||
"items_count": "{count, plural, one {# item} other {# items}}",
|
||||
"jobs": "Jobs",
|
||||
"json_editor": "JSON editor",
|
||||
@@ -1409,7 +1404,6 @@
|
||||
"link_to_oauth": "Link to OAuth",
|
||||
"linked_oauth_account": "Linked OAuth account",
|
||||
"list": "List",
|
||||
"live": "Live",
|
||||
"loading": "Loading",
|
||||
"loading_search_results_failed": "Loading search results failed",
|
||||
"local": "Local",
|
||||
@@ -1588,10 +1582,9 @@
|
||||
"mobile_app": "Mobile App",
|
||||
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
|
||||
"model": "Model",
|
||||
"modify_date": "Modify Date",
|
||||
"month": "Month",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"more": "More",
|
||||
"motion": "Motion",
|
||||
"move": "Move",
|
||||
"move_down": "Move down",
|
||||
"move_off_locked_folder": "Move out of locked folder",
|
||||
@@ -1637,6 +1630,7 @@
|
||||
"next": "Next",
|
||||
"next_memory": "Next memory",
|
||||
"no": "No",
|
||||
"no_actions_added": "No actions added yet",
|
||||
"no_albums_found": "No albums found",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
@@ -1653,6 +1647,7 @@
|
||||
"no_exif_info_available": "No exif info available",
|
||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||
"no_filters_added": "No filters added yet",
|
||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||
"no_local_assets_found": "No local assets found with this checksum",
|
||||
"no_location_set": "No location set",
|
||||
@@ -1665,7 +1660,6 @@
|
||||
"no_results": "No results",
|
||||
"no_results_description": "Try a synonym or more general keyword",
|
||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||
"no_steps": "No steps added yet",
|
||||
"no_uploads_in_progress": "No uploads in progress",
|
||||
"none": "None",
|
||||
"not_allowed": "Not allowed",
|
||||
@@ -1674,6 +1668,7 @@
|
||||
"not_selected": "Not selected",
|
||||
"notes": "Notes",
|
||||
"nothing_here_yet": "Nothing here yet",
|
||||
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
|
||||
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
|
||||
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
|
||||
"notification_permission_list_tile_enable_button": "Enable Notifications",
|
||||
@@ -1711,7 +1706,6 @@
|
||||
"organize_into_albums": "Organize into albums",
|
||||
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
|
||||
"organize_your_library": "Organize your library",
|
||||
"orientation": "Orientation",
|
||||
"original": "original",
|
||||
"other": "Other",
|
||||
"other_devices": "Other devices",
|
||||
@@ -1803,8 +1797,6 @@
|
||||
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
|
||||
"play_transcoded_video": "Play transcoded video",
|
||||
"please_auth_to_access": "Please authenticate to access",
|
||||
"plugin_method_filter_type": "Filter",
|
||||
"plugin_method_filter_type_description": "This method can filter events and conditionally prevent subsequent steps from running",
|
||||
"port": "Port",
|
||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||
"preferences_settings_title": "Preferences",
|
||||
@@ -1826,7 +1818,6 @@
|
||||
"profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.",
|
||||
"profile_image_of_user": "Profile image of {user}",
|
||||
"profile_picture_set": "Profile picture set.",
|
||||
"projection_type": "Projection Type",
|
||||
"public_album": "Public album",
|
||||
"public_share": "Public Share",
|
||||
"purchase_account_info": "Supporter",
|
||||
@@ -2196,9 +2187,7 @@
|
||||
"show_in_timeline": "Show in timeline",
|
||||
"show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
|
||||
"show_keyboard_shortcuts": "Show keyboard shortcuts",
|
||||
"show_less": "Show less",
|
||||
"show_metadata": "Show metadata",
|
||||
"show_more_fields": "{count, plural, one {Show # more field} other {Show # more fields}}",
|
||||
"show_or_hide_info": "Show or hide info",
|
||||
"show_password": "Show password",
|
||||
"show_person_options": "Show person options",
|
||||
@@ -2250,10 +2239,6 @@
|
||||
"start_date_before_end_date": "Start date must be before end date",
|
||||
"state": "State",
|
||||
"status": "Status",
|
||||
"step_delete": "Delete step",
|
||||
"step_delete_confirm": "Are you sure you want to delete this step?",
|
||||
"step_details": "Step details",
|
||||
"steps": "Steps",
|
||||
"stop_casting": "Stop casting",
|
||||
"stop_motion_photo": "Stop Motion Photo",
|
||||
"stop_photo_sharing": "Stop sharing your photos?",
|
||||
@@ -2347,7 +2332,7 @@
|
||||
"trash_page_title": "Trash ({count})",
|
||||
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
|
||||
"trigger": "Trigger",
|
||||
"trigger_asset_uploaded": "Asset Upload",
|
||||
"trigger_asset_uploaded": "Asset Uploaded",
|
||||
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
|
||||
"trigger_description": "An event that kicks off the workflow",
|
||||
"trigger_person_recognized": "Person Recognized",
|
||||
@@ -2387,6 +2372,7 @@
|
||||
"unsupported_field_type": "Unsupported field type",
|
||||
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
|
||||
"untagged": "Untagged",
|
||||
"untitled_workflow": "Untitled workflow",
|
||||
"up_next": "Up next",
|
||||
"update_location_action_prompt": "Update the location of {count} selected assets with:",
|
||||
"updated_at": "Updated",
|
||||
@@ -2478,7 +2464,6 @@
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
"workflow": "Workflow",
|
||||
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
|
||||
"workflow_deleted": "Workflow deleted",
|
||||
"workflow_description": "Workflow description",
|
||||
|
||||
@@ -2,7 +2,7 @@ experimental_monorepo_root = true
|
||||
|
||||
[monorepo]
|
||||
config_roots = [
|
||||
"packages/plugin-core",
|
||||
"packages/plugins",
|
||||
"server",
|
||||
"packages/cli",
|
||||
"deployment",
|
||||
@@ -22,22 +22,12 @@ terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
java = "21.0.2"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
"github:webassembly/binaryen" = "version_124"
|
||||
"github:extism/js-pdk" = "1.6.0"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.37.0"
|
||||
bin = "dcm"
|
||||
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm".platforms]
|
||||
linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
|
||||
linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
|
||||
macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
|
||||
macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
|
||||
windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"]
|
||||
version = "7.1.3-6"
|
||||
|
||||
@@ -51,12 +41,6 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
|
||||
experimental = true
|
||||
pin = true
|
||||
|
||||
[tasks.plugins]
|
||||
run = [
|
||||
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
|
||||
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build"
|
||||
]
|
||||
|
||||
[tasks.open-api-typescript]
|
||||
run = [
|
||||
"oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts",
|
||||
@@ -71,8 +55,6 @@ run = "bash ./bin/generate-dart-sdk.sh"
|
||||
[tasks.open-api]
|
||||
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
|
||||
run = [
|
||||
{ task = "//:plugins" },
|
||||
{ task = "//server:build" },
|
||||
{ task = "//server:install" },
|
||||
{ task = "//server:build" },
|
||||
{ task = "//server:sync-open-api" },
|
||||
|
||||
@@ -17,6 +17,8 @@ import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
@@ -50,6 +52,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
PermissionApi.setUp(messenger, PermissionApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
|
||||
@@ -23,8 +23,6 @@ import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||
|
||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||
|
||||
class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
@@ -230,6 +228,7 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
private val onComplete: () -> Unit,
|
||||
) : UrlRequest.Callback() {
|
||||
private var buffer: NativeByteBuffer? = null
|
||||
private var wrapped: ByteBuffer? = null
|
||||
private var error: Exception? = null
|
||||
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
|
||||
@@ -243,16 +242,15 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
|
||||
try {
|
||||
// Content-Length is a size hint only. With Content-Encoding (gzip/br/...),
|
||||
// Cronet auto-decompresses and writes decompressed bytes to our buffer, which
|
||||
// may exceed the wire/compressed Content-Length. Always use the growable
|
||||
// buffer path so we can't overflow.
|
||||
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
|
||||
// Cap the up-front alloc: Content-Length is untrusted and can be huge or near
|
||||
// Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over.
|
||||
val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE
|
||||
buffer = NativeByteBuffer(initialSize)
|
||||
request.read(buffer!!.wrapRemaining())
|
||||
if (contentLength > 0) {
|
||||
buffer = NativeByteBuffer(contentLength + 1)
|
||||
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
|
||||
request.read(wrapped)
|
||||
} else {
|
||||
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
|
||||
request.read(buffer!!.wrapRemaining())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e
|
||||
return request.cancel()
|
||||
@@ -265,14 +263,14 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
byteBuffer: ByteBuffer
|
||||
) {
|
||||
try {
|
||||
// Always pass a fresh wrap so byteBuffer.position() represents only the
|
||||
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
|
||||
// ByteBuffer breaks advance(): Cronet's position keeps accumulating
|
||||
// across reads, which would double-count previous iterations' bytes.
|
||||
val buf = buffer!!.run {
|
||||
advance(byteBuffer.position())
|
||||
ensureHeadroom()
|
||||
wrapRemaining()
|
||||
val buf = if (wrapped == null) {
|
||||
buffer!!.run {
|
||||
advance(byteBuffer.position())
|
||||
ensureHeadroom()
|
||||
wrapRemaining()
|
||||
}
|
||||
} else {
|
||||
wrapped
|
||||
}
|
||||
request.read(buf)
|
||||
} catch (e: Exception) {
|
||||
@@ -282,6 +280,7 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
wrapped?.let { buffer!!.advance(it.position()) }
|
||||
onSuccess(buffer!!)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object PermissionApiPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : RuntimeException()
|
||||
|
||||
enum class PermissionStatus(val raw: Int) {
|
||||
GRANTED(0),
|
||||
DENIED(1),
|
||||
PERMANENTLY_DENIED(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): PermissionStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
PermissionStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is PermissionStatus -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface PermissionApi {
|
||||
fun isIgnoringBatteryOptimizations(): PermissionStatus
|
||||
|
||||
companion object {
|
||||
/** The codec used by PermissionApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
PermissionApiPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.isIgnoringBatteryOptimizations())
|
||||
} catch (exception: Throwable) {
|
||||
PermissionApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
|
||||
class PermissionApiImpl(context: Context) : PermissionApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private val powerManager =
|
||||
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
|
||||
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
|
||||
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
||||
return PermissionStatus.GRANTED
|
||||
}
|
||||
return PermissionStatus.DENIED
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
|
||||
var domainAlbum = PlatformAlbum(
|
||||
id: album.localIdentifier,
|
||||
name: album.localizedTitle ?? album.localIdentifier,
|
||||
name: album.localizedTitle!,
|
||||
updatedAt: nil,
|
||||
isCloud: isCloud,
|
||||
assetCount: Int64(assets.count)
|
||||
|
||||
@@ -18,7 +18,3 @@ enum CleanupStep { selectDate, scan, delete }
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
enum AssetDateAggregation { start, end }
|
||||
|
||||
enum SlideshowLook { contain, cover, blurredBackground }
|
||||
|
||||
enum SlideshowDirection { forward, backward, shuffle }
|
||||
|
||||
@@ -11,7 +11,6 @@ class RemoteAsset extends BaseAsset {
|
||||
final String ownerId;
|
||||
final String? stackId;
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
@@ -32,7 +31,6 @@ class RemoteAsset extends BaseAsset {
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -50,8 +48,6 @@ class RemoteAsset extends BaseAsset {
|
||||
@override
|
||||
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
|
||||
|
||||
bool get isTrashed => deletedAt != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
@@ -90,8 +86,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
uploadedAt == other.uploadedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -103,8 +98,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
uploadedAt.hashCode;
|
||||
|
||||
RemoteAsset copyWith({
|
||||
String? id,
|
||||
@@ -125,7 +119,6 @@ class RemoteAsset extends BaseAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -146,7 +139,6 @@ class RemoteAsset extends BaseAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +156,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
super.uploadedAt,
|
||||
super.deletedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationMs,
|
||||
@@ -202,7 +193,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
DateTime? deletedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationMs,
|
||||
@@ -224,7 +214,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
uploadedAt: uploadedAt ?? this.uploadedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
class AlbumConfig {
|
||||
final AlbumSortMode sortMode;
|
||||
final bool isReverse;
|
||||
final bool isGrid;
|
||||
|
||||
const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false});
|
||||
|
||||
AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig(
|
||||
sortMode: sortMode ?? this.sortMode,
|
||||
isReverse: isReverse ?? this.isReverse,
|
||||
isGrid: isGrid ?? this.isGrid,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(sortMode, isReverse, isGrid);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)';
|
||||
}
|
||||
@@ -1,51 +1,26 @@
|
||||
import 'package:immich_mobile/domain/models/config/album_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
|
||||
|
||||
class AppConfig {
|
||||
final ThemeConfig theme;
|
||||
final CleanupConfig cleanup;
|
||||
final MapConfig map;
|
||||
final TimelineConfig timeline;
|
||||
final ImageConfig image;
|
||||
final ViewerConfig viewer;
|
||||
final SlideshowConfig slideshow;
|
||||
final AlbumConfig album;
|
||||
|
||||
const AppConfig({
|
||||
this.theme = const .new(),
|
||||
this.cleanup = const .new(),
|
||||
this.map = const .new(),
|
||||
this.timeline = const .new(),
|
||||
this.image = const .new(),
|
||||
this.viewer = const .new(),
|
||||
this.slideshow = const .new(),
|
||||
this.album = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
ThemeConfig? theme,
|
||||
CleanupConfig? cleanup,
|
||||
MapConfig? map,
|
||||
TimelineConfig? timeline,
|
||||
ImageConfig? image,
|
||||
ViewerConfig? viewer,
|
||||
SlideshowConfig? slideshow,
|
||||
AlbumConfig? album,
|
||||
}) => .new(
|
||||
AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, TimelineConfig? timeline}) => .new(
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
map: map ?? this.map,
|
||||
timeline: timeline ?? this.timeline,
|
||||
image: image ?? this.image,
|
||||
viewer: viewer ?? this.viewer,
|
||||
slideshow: slideshow ?? this.slideshow,
|
||||
album: album ?? this.album,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -55,16 +30,11 @@ class AppConfig {
|
||||
other.theme == theme &&
|
||||
other.cleanup == cleanup &&
|
||||
other.map == map &&
|
||||
other.timeline == timeline &&
|
||||
other.image == image &&
|
||||
other.viewer == viewer &&
|
||||
other.slideshow == slideshow &&
|
||||
other.album == album);
|
||||
other.timeline == timeline);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album);
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album)';
|
||||
String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline)';
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
class ImageConfig {
|
||||
final bool preferRemote;
|
||||
final bool loadOriginal;
|
||||
|
||||
const ImageConfig({this.preferRemote = false, this.loadOriginal = false});
|
||||
|
||||
ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) =>
|
||||
ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(preferRemote, loadOriginal);
|
||||
|
||||
@override
|
||||
String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)';
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class NetworkConfig {
|
||||
final bool autoEndpointSwitching;
|
||||
final String? preferredWifiName;
|
||||
final String? localEndpoint;
|
||||
final List<String> externalEndpointList;
|
||||
final Map<String, String> customHeaders;
|
||||
|
||||
const NetworkConfig({
|
||||
this.autoEndpointSwitching = false,
|
||||
this.preferredWifiName,
|
||||
this.localEndpoint,
|
||||
this.externalEndpointList = const [],
|
||||
this.customHeaders = const {},
|
||||
});
|
||||
|
||||
NetworkConfig copyWith({
|
||||
bool? autoEndpointSwitching,
|
||||
String? preferredWifiName,
|
||||
String? localEndpoint,
|
||||
List<String>? externalEndpointList,
|
||||
Map<String, String>? customHeaders,
|
||||
}) => NetworkConfig(
|
||||
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
|
||||
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
|
||||
localEndpoint: localEndpoint ?? this.localEndpoint,
|
||||
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
|
||||
customHeaders: customHeaders ?? this.customHeaders,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is NetworkConfig &&
|
||||
other.autoEndpointSwitching == autoEndpointSwitching &&
|
||||
other.preferredWifiName == preferredWifiName &&
|
||||
other.localEndpoint == localEndpoint &&
|
||||
listEquals(other.externalEndpointList, externalEndpointList) &&
|
||||
mapEquals(other.customHeaders, customHeaders));
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
autoEndpointSwitching,
|
||||
preferredWifiName,
|
||||
localEndpoint,
|
||||
Object.hashAll(externalEndpointList),
|
||||
Object.hashAllUnordered(customHeaders.entries.map((e) => Object.hash(e.key, e.value))),
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'NetworkConfig(autoEndpointSwitching: $autoEndpointSwitching, preferredWifiName: $preferredWifiName, localEndpoint: $localEndpoint, externalEndpointList: $externalEndpointList, customHeaders: $customHeaders)';
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
|
||||
class SlideshowConfig {
|
||||
final bool transition;
|
||||
final bool repeat;
|
||||
final int duration;
|
||||
final SlideshowLook look;
|
||||
final SlideshowDirection direction;
|
||||
|
||||
const SlideshowConfig({
|
||||
this.transition = true,
|
||||
this.repeat = true,
|
||||
this.duration = 5,
|
||||
this.look = SlideshowLook.contain,
|
||||
this.direction = SlideshowDirection.forward,
|
||||
});
|
||||
|
||||
SlideshowConfig copyWith({
|
||||
bool? transition,
|
||||
bool? repeat,
|
||||
int? duration,
|
||||
SlideshowLook? look,
|
||||
SlideshowDirection? direction,
|
||||
}) => SlideshowConfig(
|
||||
transition: transition ?? this.transition,
|
||||
repeat: repeat ?? this.repeat,
|
||||
duration: duration ?? this.duration,
|
||||
look: look ?? this.look,
|
||||
direction: direction ?? this.direction,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is SlideshowConfig &&
|
||||
other.transition == transition &&
|
||||
other.repeat == repeat &&
|
||||
other.duration == duration &&
|
||||
other.look == look &&
|
||||
other.direction == direction);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
class SystemConfig {
|
||||
final LogLevel logLevel;
|
||||
final NetworkConfig network;
|
||||
|
||||
const SystemConfig({this.logLevel = .info, this.network = const .new()});
|
||||
const SystemConfig({this.logLevel = .info});
|
||||
|
||||
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
|
||||
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
|
||||
SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
|
||||
bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(logLevel, network);
|
||||
int get hashCode => logLevel.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel)';
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
class ViewerConfig {
|
||||
final bool loopVideo;
|
||||
final bool loadOriginalVideo;
|
||||
final bool autoPlayVideo;
|
||||
final bool tapToNavigate;
|
||||
|
||||
const ViewerConfig({
|
||||
this.loopVideo = true,
|
||||
this.loadOriginalVideo = false,
|
||||
this.autoPlayVideo = true,
|
||||
this.tapToNavigate = false,
|
||||
});
|
||||
|
||||
ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) =>
|
||||
ViewerConfig(
|
||||
loopVideo: loopVideo ?? this.loopVideo,
|
||||
loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo,
|
||||
autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo,
|
||||
tapToNavigate: tapToNavigate ?? this.tapToNavigate,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is ViewerConfig &&
|
||||
other.loopVideo == loopVideo &&
|
||||
other.loadOriginalVideo == loadOriginalVideo &&
|
||||
other.autoPlayVideo == autoPlayVideo &&
|
||||
other.tapToNavigate == tapToNavigate);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)';
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
enum MetadataDomain<T extends Object> {
|
||||
appConfig<AppConfig>('config.app'),
|
||||
@@ -25,43 +24,6 @@ enum MetadataKey<T extends Object> {
|
||||
themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
|
||||
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
|
||||
|
||||
// Image
|
||||
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
|
||||
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
|
||||
|
||||
// Viewer
|
||||
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
|
||||
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
|
||||
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
|
||||
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
|
||||
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
|
||||
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
|
||||
networkExternalEndpointList<List<String>>(
|
||||
.systemConfig,
|
||||
'network.externalEndpointList',
|
||||
[],
|
||||
_ListCodec(_PrimitiveCodec.string),
|
||||
),
|
||||
networkCustomHeaders<Map<String, String>>(
|
||||
.systemConfig,
|
||||
'network.customHeaders',
|
||||
{},
|
||||
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(
|
||||
.appConfig,
|
||||
'album.sortMode',
|
||||
AlbumSortMode.mostRecent,
|
||||
_EnumCodec(AlbumSortMode.values),
|
||||
),
|
||||
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
||||
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(
|
||||
@@ -92,19 +54,7 @@ enum MetadataKey<T extends Object> {
|
||||
),
|
||||
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
|
||||
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
|
||||
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
|
||||
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(
|
||||
.appConfig,
|
||||
'slideshow.direction',
|
||||
SlideshowDirection.forward,
|
||||
_EnumCodec(SlideshowDirection.values),
|
||||
);
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false);
|
||||
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
@@ -171,47 +121,6 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
DateTime? decode(String raw) => DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
|
||||
final _MetadataCodec<K> _keyCodec;
|
||||
final _MetadataCodec<V> _valueCodec;
|
||||
|
||||
const _MapCodec(this._keyCodec, this._valueCodec);
|
||||
|
||||
@override
|
||||
String encode(Map<K, V> value) {
|
||||
final entries = <String, String>{};
|
||||
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V>? decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return null;
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
return null;
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
if (k == null || v == null) {
|
||||
return null;
|
||||
}
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
|
||||
final _MetadataCodec<T> _elementCodec;
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
|
||||
enum Setting<T> {
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, false),
|
||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
|
||||
autoPlayVideo<bool>(StoreKey.autoPlayVideo, true),
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
|
||||
enableBackup<bool>(StoreKey.enableBackup, false);
|
||||
|
||||
|
||||
@@ -4,20 +4,51 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
/// Defines the data type for each value
|
||||
enum StoreKey<T> {
|
||||
version<int>._(0),
|
||||
assetETag<String>._(1),
|
||||
currentUser<UserDto>._(2),
|
||||
deviceIdHash<int>._(3),
|
||||
deviceId<String>._(4),
|
||||
backupFailedSince<DateTime>._(5),
|
||||
backupRequireWifi<bool>._(6),
|
||||
backupRequireCharging<bool>._(7),
|
||||
backupTriggerDelay<int>._(8),
|
||||
serverUrl<String>._(10),
|
||||
accessToken<String>._(11),
|
||||
serverEndpoint<String>._(12),
|
||||
autoBackup<bool>._(13),
|
||||
backgroundBackup<bool>._(14),
|
||||
sslClientCertData<String>._(15),
|
||||
sslClientPasswd<String>._(16),
|
||||
uploadErrorNotificationGracePeriod<int>._(106),
|
||||
thumbnailCacheSize<int>._(110),
|
||||
imageCacheSize<int>._(111),
|
||||
albumThumbnailCacheSize<int>._(112),
|
||||
selectedAlbumSortOrder<int>._(113),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
preferRemoteImage<bool>._(116),
|
||||
selfSignedCert<bool>._(120),
|
||||
selectedAlbumSortReverse<bool>._(123),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
customHeaders<String>._(127),
|
||||
syncAlbums<bool>._(131),
|
||||
|
||||
// Auto endpoint switching
|
||||
autoEndpointSwitching<bool>._(132),
|
||||
preferredWifiName<String>._(133),
|
||||
localEndpoint<String>._(134),
|
||||
externalEndpointList<String>._(135),
|
||||
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
albumGridView<bool>._(140),
|
||||
loadOriginal<bool>._(101),
|
||||
|
||||
// Image viewer navigation settings
|
||||
loopVideo<bool>._(117),
|
||||
loadOriginalVideo<bool>._(136),
|
||||
autoPlayVideo<bool>._(139),
|
||||
tapToNavigate<bool>._(141),
|
||||
|
||||
// Experimental stuff
|
||||
enableBackup<bool>._(1003),
|
||||
@@ -26,20 +57,6 @@ enum StoreKey<T> {
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacySelectedAlbumSortOrder<int>._(113),
|
||||
legacySelectedAlbumSortReverse<bool>._(123),
|
||||
legacyAlbumGridView<bool>._(140),
|
||||
legacyAutoEndpointSwitching<bool>._(132),
|
||||
legacyPreferredWifiName<String>._(133),
|
||||
legacyLocalEndpoint<String>._(134),
|
||||
legacyExternalEndpointList<String>._(135),
|
||||
legacyCustomHeaders<String>._(127),
|
||||
legacyLoopVideo<bool>._(117),
|
||||
legacyLoadOriginalVideo<bool>._(136),
|
||||
legacyAutoPlayVideo<bool>._(139),
|
||||
legacyTapToNavigate<bool>._(141),
|
||||
legacyPreferRemoteImage<bool>._(116),
|
||||
legacyLoadOriginal<bool>._(101),
|
||||
legacyPrimaryColor<String>._(128),
|
||||
legacyDynamicTheme<bool>._(129),
|
||||
legacyColorfulInterface<bool>._(130),
|
||||
|
||||
@@ -9,47 +9,12 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Categorizes a heterogeneous asset selection into the candidates that can
|
||||
/// be added to an album immediately (already on the server) and the local-only
|
||||
/// candidates that must be uploaded first.
|
||||
class AlbumAssetCandidates {
|
||||
final List<String> remoteAssetIds;
|
||||
final List<LocalAsset> localAssetsToUpload;
|
||||
|
||||
const AlbumAssetCandidates({required this.remoteAssetIds, required this.localAssetsToUpload});
|
||||
}
|
||||
|
||||
class RemoteAlbumService {
|
||||
static final _logger = Logger('RemoteAlbumService');
|
||||
|
||||
final DriftRemoteAlbumRepository _repository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final ForegroundUploadService _uploadService;
|
||||
|
||||
const RemoteAlbumService(this._repository, this._albumApiRepository, this._uploadService);
|
||||
|
||||
/// Categorizes a heterogeneous asset selection into already-on-server IDs
|
||||
/// and local assets that still need to be uploaded.
|
||||
static AlbumAssetCandidates categorizeCandidates(Iterable<BaseAsset> assets) {
|
||||
final remoteIds = <String>[];
|
||||
final localToUpload = <LocalAsset>[];
|
||||
for (final asset in assets) {
|
||||
if (asset is RemoteAsset) {
|
||||
remoteIds.add(asset.id);
|
||||
} else if (asset is LocalAsset) {
|
||||
final remoteId = asset.remoteId;
|
||||
if (remoteId != null) {
|
||||
remoteIds.add(remoteId);
|
||||
} else {
|
||||
localToUpload.add(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
return AlbumAssetCandidates(remoteAssetIds: remoteIds, localAssetsToUpload: localToUpload);
|
||||
}
|
||||
const RemoteAlbumService(this._repository, this._albumApiRepository);
|
||||
|
||||
Stream<RemoteAlbum?> watchAlbum(String albumId) {
|
||||
return _repository.watchAlbum(albumId);
|
||||
@@ -183,122 +148,6 @@ class RemoteAlbumService {
|
||||
return album.added.length;
|
||||
}
|
||||
|
||||
/// !TODO The name here is not clear as we have addAssets method above,
|
||||
/// which is only add remote assets to album, for the next PR, we will allow
|
||||
/// adding local assets from album from the timeline as well with this flow.
|
||||
/// So saving that for the next refactor
|
||||
Future<int> addAssetsToAlbum({
|
||||
required String albumId,
|
||||
required UserDto uploader,
|
||||
required AlbumAssetCandidates candidates,
|
||||
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
|
||||
}) async {
|
||||
int addedCount = 0;
|
||||
if (candidates.remoteAssetIds.isNotEmpty) {
|
||||
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
|
||||
}
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
addedCount += await _uploadAndAddLocals(albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks);
|
||||
}
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// Creates an album, seeding it with already-remote asset IDs, then uploads
|
||||
/// local-only assets and links each one as it finishes.
|
||||
Future<RemoteAlbum> createAlbumWithAssets({
|
||||
required String title,
|
||||
required UserDto owner,
|
||||
String? description,
|
||||
AlbumAssetCandidates candidates = const AlbumAssetCandidates(remoteAssetIds: [], localAssetsToUpload: []),
|
||||
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
|
||||
}) async {
|
||||
final album = await createAlbum(
|
||||
title: title,
|
||||
owner: owner,
|
||||
description: description,
|
||||
assetIds: candidates.remoteAssetIds,
|
||||
);
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
await _uploadAndAddLocals(album.id, owner, candidates.localAssetsToUpload, uploadCallbacks);
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
Future<int> _uploadAndAddLocals(
|
||||
String albumId,
|
||||
UserDto uploader,
|
||||
List<LocalAsset> localAssets,
|
||||
UploadCallbacks userCallbacks,
|
||||
) async {
|
||||
int addedCount = 0;
|
||||
final pendingAdds = <Future<void>>[];
|
||||
final localById = {for (final a in localAssets) a.id: a};
|
||||
|
||||
final wrappedCallbacks = UploadCallbacks(
|
||||
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
|
||||
'Upload progress callback failed for $localId',
|
||||
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
|
||||
),
|
||||
onICloudProgress: (localId, progress) => _runUploadCallback(
|
||||
'iCloud progress callback failed for $localId',
|
||||
() => userCallbacks.onICloudProgress?.call(localId, progress),
|
||||
),
|
||||
onError: (localId, errorMessage) => _runUploadCallback(
|
||||
'Upload error callback failed for $localId',
|
||||
() => userCallbacks.onError?.call(localId, errorMessage),
|
||||
),
|
||||
onSuccess: (localId, remoteId) {
|
||||
_runUploadCallback(
|
||||
'Upload success callback failed for $localId',
|
||||
() => userCallbacks.onSuccess?.call(localId, remoteId),
|
||||
);
|
||||
final source = localById[localId];
|
||||
if (source == null) {
|
||||
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
|
||||
return;
|
||||
}
|
||||
pendingAdds.add(
|
||||
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
|
||||
.then<void>((added) {
|
||||
addedCount += added;
|
||||
})
|
||||
.catchError((Object error, StackTrace stack) {
|
||||
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
|
||||
await Future.wait(pendingAdds);
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
void _runUploadCallback(String message, void Function() callback) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error, stack) {
|
||||
_logger.warning(message, error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// Links a freshly-uploaded asset to an album, ensuring the local DB
|
||||
/// reflects the change without waiting for the next sync. We call the API
|
||||
/// (server is the source of truth), then upsert a placeholder
|
||||
/// `remote_asset_entity` row from the local source so the FK-protected
|
||||
/// junction insert succeeds. Sync overwrites the placeholder later with
|
||||
/// the authoritative server data.
|
||||
Future<int> _linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async {
|
||||
final result = await _albumApiRepository.addAssets(albumId, [remoteId]);
|
||||
if (result.added.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await _repository.upsertRemoteAssetStub(remoteId: remoteId, ownerId: uploader.id, source: source);
|
||||
await _repository.addAssets(albumId, result.added);
|
||||
return result.added.length;
|
||||
}
|
||||
|
||||
Future<void> deleteAlbum(String albumId) async {
|
||||
await _albumApiRepository.deleteAlbum(albumId);
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
|
||||
|
||||
final tagServiceProvider = Provider<TagService>((ref) => TagService(ref.watch(tagsApiRepositoryProvider)));
|
||||
|
||||
class TagService {
|
||||
final TagsApiRepository _repository;
|
||||
|
||||
const TagService(this._repository);
|
||||
|
||||
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
|
||||
return _repository.bulkTagAssets(assetIds, tagIds);
|
||||
}
|
||||
|
||||
Future<Set<Tag>> getAllTags() async {
|
||||
final dtos = await _repository.getAllTags();
|
||||
if (dtos == null) {
|
||||
return {};
|
||||
}
|
||||
return dtos.map((dto) => Tag.fromDto(dto)).toSet();
|
||||
}
|
||||
|
||||
Future<List<Tag>> upsertTags(List<String> tags) async {
|
||||
final dtos = await _repository.upsertTags(tags);
|
||||
if (dtos == null) {
|
||||
return [];
|
||||
}
|
||||
return dtos.map((dto) => Tag.fromDto(dto)).toList();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
|
||||
}
|
||||
|
||||
String? get nullIfEmpty => isEmpty ? null : this;
|
||||
}
|
||||
|
||||
extension DurationExtension on String {
|
||||
|
||||
@@ -74,6 +74,5 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
localId: localId,
|
||||
stackId: stackId,
|
||||
isEdited: isEdited,
|
||||
deletedAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
@@ -133,37 +132,9 @@ extension<T extends Object> on MetadataDomain<T> {
|
||||
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
|
||||
storageIndicator: repo._read(.timelineStorageIndicator),
|
||||
),
|
||||
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
|
||||
viewer: .new(
|
||||
loopVideo: repo._read(.viewerLoopVideo),
|
||||
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
|
||||
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
|
||||
tapToNavigate: repo._read(.viewerTapToNavigate),
|
||||
),
|
||||
slideshow: .new(
|
||||
transition: repo._read(.slideshowTransition),
|
||||
repeat: repo._read(.slideshowRepeat),
|
||||
duration: repo._read(.slideshowDuration),
|
||||
look: repo._read(.slideshowLook),
|
||||
direction: repo._read(.slideshowDirection),
|
||||
),
|
||||
album: .new(
|
||||
sortMode: repo._read(.albumSortMode),
|
||||
isReverse: repo._read(.albumIsReverse),
|
||||
isGrid: repo._read(.albumIsGrid),
|
||||
),
|
||||
);
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(
|
||||
logLevel: repo._read(.logLevel),
|
||||
network: .new(
|
||||
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
|
||||
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
|
||||
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
|
||||
externalEndpointList: repo._read(.networkExternalEndpointList),
|
||||
customHeaders: repo._read(.networkCustomHeaders),
|
||||
),
|
||||
);
|
||||
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
enum SortRemoteAlbumsBy { id, updatedAt }
|
||||
@@ -160,7 +159,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
createdAt: Value(album.createdAt),
|
||||
updatedAt: Value(album.updatedAt),
|
||||
description: Value(album.description),
|
||||
thumbnailAssetId: Value(album.thumbnailAssetId ?? (assetIds.isNotEmpty ? assetIds.first : null)),
|
||||
thumbnailAssetId: Value(album.thumbnailAssetId),
|
||||
isActivityEnabled: Value(album.isActivityEnabled),
|
||||
order: Value(album.order),
|
||||
);
|
||||
@@ -275,59 +274,17 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
Future<int> addAssets(String albumId, List<String> assetIds) async {
|
||||
if (assetIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final albumAssets = assetIds.map(
|
||||
(assetId) => RemoteAlbumAssetEntityCompanion(albumId: Value(albumId), assetId: Value(assetId)),
|
||||
);
|
||||
|
||||
await _db.transaction(() async {
|
||||
await _db.batch((batch) {
|
||||
batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets);
|
||||
});
|
||||
|
||||
final album = _db.update(_db.remoteAlbumEntity)
|
||||
..where((row) => row.id.equals(albumId) & row.thumbnailAssetId.isNull());
|
||||
|
||||
await album.write(RemoteAlbumEntityCompanion(thumbnailAssetId: Value(assetIds.first)));
|
||||
await _db.batch((batch) {
|
||||
batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets);
|
||||
});
|
||||
|
||||
return assetIds.length;
|
||||
}
|
||||
|
||||
/// Inserts a placeholder `remote_asset_entity` row from a freshly-uploaded
|
||||
/// local asset. Skips silently if a row with the same id or
|
||||
/// (owner_id, checksum) already exists — sync will overwrite with the
|
||||
/// authoritative server data once the AssetUploadReadyV1 event is processed.
|
||||
Future<void> upsertRemoteAssetStub({
|
||||
required String remoteId,
|
||||
required String ownerId,
|
||||
required LocalAsset source,
|
||||
}) async {
|
||||
await _db
|
||||
.into(_db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion(
|
||||
id: Value(remoteId),
|
||||
ownerId: Value(ownerId),
|
||||
checksum: Value(source.checksum ?? remoteId),
|
||||
name: Value(source.name),
|
||||
type: Value(source.type),
|
||||
createdAt: Value(source.createdAt),
|
||||
updatedAt: Value(source.updatedAt),
|
||||
width: Value(source.width),
|
||||
height: Value(source.height),
|
||||
durationMs: Value(source.durationMs),
|
||||
isFavorite: Value(source.isFavorite),
|
||||
visibility: const Value(AssetVisibility.timeline),
|
||||
isEdited: Value(source.isEdited),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addUsers(String albumId, List<String> userIds) {
|
||||
final albumUsers = userIds.map(
|
||||
(assetId) => RemoteAlbumUserEntityCompanion(
|
||||
|
||||
@@ -14,13 +14,4 @@ class TagsApiRepository extends ApiRepository {
|
||||
Future<List<TagResponseDto>?> getAllTags() async {
|
||||
return await _api.getAllTags();
|
||||
}
|
||||
|
||||
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
|
||||
final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds));
|
||||
return response?.count ?? 0;
|
||||
}
|
||||
|
||||
Future<List<TagResponseDto>?> upsertTags(List<String> tags) async {
|
||||
return _api.upsertTags(TagUpsertDto(tags: tags));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
@@ -15,11 +16,16 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -162,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
),
|
||||
),
|
||||
},
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text("view_details".t(context: context)),
|
||||
),
|
||||
const _BackupFooter(),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -177,6 +179,137 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupFooter extends ConsumerStatefulWidget {
|
||||
const _BackupFooter();
|
||||
|
||||
@override
|
||||
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
|
||||
}
|
||||
|
||||
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
|
||||
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
|
||||
unawaited(ref.read(batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
|
||||
}
|
||||
}
|
||||
|
||||
void showPermissionsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
content: Text(context.t.notification_permission_dialog_content),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
labelText: context.t.cancel,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => ContextHelper(ctx).pop(),
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: context.t.settings,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () {
|
||||
ContextHelper(context).pop();
|
||||
openAppSettings();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showBatteryOptimizationInfo() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.backup_controller_page_background_battery_info_title),
|
||||
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
labelText: context.t.backup_controller_page_background_battery_info_link,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: context.t.backup_controller_page_background_battery_info_ok,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => ContextHelper(ctx).pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBackupEnabled = ref.watch(_backupStatusProvider).valueOrNull ?? false;
|
||||
final notificationStatus = ref.watch(notificationPermissionProvider);
|
||||
final batteryOptimizationStatus = ref.watch(batteryOptimizationProvider).valueOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
|
||||
if (notificationStatus != PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
iconAlignment: .end,
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.notification_backup_reliability,
|
||||
textAlign: TextAlign.left,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
|
||||
if (p == PermissionStatus.permanentlyDenied) {
|
||||
showPermissionsDialog();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (notificationStatus != PermissionStatus.granted && batteryOptimizationStatus != PermissionStatus.granted)
|
||||
const Divider(indent: 32, endIndent: 32),
|
||||
if (batteryOptimizationStatus != PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
iconAlignment: .end,
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.battery_optimization_backup_reliability,
|
||||
textAlign: TextAlign.left,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: showBatteryOptimizationInfo,
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text(context.t.view_details),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupAlbumSelectionCard extends ConsumerWidget {
|
||||
const _BackupAlbumSelectionCard();
|
||||
|
||||
@@ -527,3 +660,7 @@ class _PreparingStatusState extends ConsumerState {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _backupStatusProvider = StreamProvider.autoDispose<bool?>((ref) async* {
|
||||
yield* ref.watch(storeServiceProvider).watch(StoreKey.enableBackup);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
class SettingsHeader {
|
||||
String key = "";
|
||||
@@ -22,14 +24,17 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
final headers = useState<List<SettingsHeader>>([]);
|
||||
final setInitialHeaders = useState(false);
|
||||
|
||||
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
var headersStr = Store.get(StoreKey.customHeaders, "");
|
||||
if (!setInitialHeaders.value) {
|
||||
storedHeaders.forEach((k, v) {
|
||||
final header = SettingsHeader();
|
||||
header.key = k;
|
||||
header.value = v;
|
||||
headers.value.add(header);
|
||||
});
|
||||
if (headersStr.isNotEmpty) {
|
||||
var customHeaders = jsonDecode(headersStr) as Map;
|
||||
customHeaders.forEach((k, v) {
|
||||
final header = SettingsHeader();
|
||||
header.key = k;
|
||||
header.value = v;
|
||||
headers.value.add(header);
|
||||
});
|
||||
}
|
||||
|
||||
// add first one to help the user
|
||||
if (headers.value.isEmpty) {
|
||||
@@ -83,8 +88,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
|
||||
final headersMap = <String, String>{};
|
||||
for (final header in headers) {
|
||||
final headersMap = {};
|
||||
for (var header in headers) {
|
||||
final key = header.key.trim();
|
||||
final value = header.value.trim();
|
||||
|
||||
@@ -94,7 +99,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
headersMap[key] = value;
|
||||
}
|
||||
|
||||
await ref.read(metadataProvider).write(MetadataKey.networkCustomHeaders, headersMap);
|
||||
var encoded = jsonEncode(headersMap);
|
||||
await Store.put(StoreKey.customHeaders, encoded);
|
||||
await ref.read(apiServiceProvider).updateHeaders();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart';
|
||||
@@ -27,41 +28,71 @@ class SharedLinkPage extends HookConsumerWidget {
|
||||
}, []);
|
||||
|
||||
Widget buildNoShares() {
|
||||
return Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.link_off, size: 100, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)),
|
||||
const SizedBox(height: 20),
|
||||
const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: const Text(
|
||||
"shared_link_manage_links",
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Icon(Icons.link_off, size: 100, color: context.themeData.iconTheme.color?.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSharesList(List<SharedLink> links) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => constraints.maxWidth > 600
|
||||
? GridView.builder(
|
||||
key: const PageStorageKey('shared-links-grid'),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisExtent: 180,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) => SharedLinkItem(links[index]),
|
||||
)
|
||||
: ListView.separated(
|
||||
key: const PageStorageKey('shared-links-list'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) => SharedLinkItem(links[index]),
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0),
|
||||
child: Text(
|
||||
"shared_link_manage_links",
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.textTheme.labelLarge?.color?.withAlpha(200)),
|
||||
).tr(),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth > 600) {
|
||||
// Two column
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisExtent: 180,
|
||||
),
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SharedLinkItem(links.elementAt(index));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Single column
|
||||
return ListView.builder(
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SharedLinkItem(links.elementAt(index));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/services/shared_link.service.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class SharedLinkEditPage extends HookConsumerWidget {
|
||||
static const int maxFutureDate = 365 * 2;
|
||||
|
||||
final SharedLink? existingLink;
|
||||
final List<String>? assetsList;
|
||||
final String? albumId;
|
||||
@@ -28,82 +23,71 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const padding = 20.0;
|
||||
final themeData = context.themeData;
|
||||
final colorScheme = context.colorScheme;
|
||||
final externalDomain = ref.watch(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
|
||||
final displayServerUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
|
||||
final expiryPresets = <(Duration, String)>[
|
||||
(Duration.zero, context.t.never),
|
||||
(const Duration(minutes: 30), context.t.shared_link_edit_expire_after_option_minutes(count: 30)),
|
||||
(const Duration(hours: 1), context.t.shared_link_edit_expire_after_option_hour),
|
||||
(const Duration(hours: 6), context.t.shared_link_edit_expire_after_option_hours(count: 6)),
|
||||
(const Duration(days: 1), context.t.shared_link_edit_expire_after_option_day),
|
||||
(const Duration(days: 7), context.t.shared_link_edit_expire_after_option_days(count: 7)),
|
||||
(const Duration(days: 30), context.t.shared_link_edit_expire_after_option_days(count: 30)),
|
||||
(const Duration(days: 90), context.t.shared_link_edit_expire_after_option_months(count: 3)),
|
||||
(const Duration(days: 365), context.t.shared_link_edit_expire_after_option_year(count: 1)),
|
||||
];
|
||||
final descriptionController = useTextEditingController(text: existingLink?.description ?? "");
|
||||
final descriptionFocusNode = useFocusNode();
|
||||
final passwordController = useTextEditingController(text: existingLink?.password ?? "");
|
||||
final slugController = useTextEditingController(text: existingLink?.slug ?? "");
|
||||
final slugFocusNode = useFocusNode();
|
||||
useListenable(slugController);
|
||||
final showMetadata = useState(existingLink?.showMetadata ?? true);
|
||||
final allowDownload = useState(existingLink?.allowDownload ?? true);
|
||||
final allowUpload = useState(existingLink?.allowUpload ?? false);
|
||||
final expiryAfter = useState<DateTime?>(existingLink?.expiresAt?.toLocal());
|
||||
final selectedPresetIndex = useState<int?>(existingLink?.expiresAt == null ? 0 : null);
|
||||
final editExpiry = useState(false);
|
||||
final expiryAfter = useState(0);
|
||||
final newShareLink = useState("");
|
||||
|
||||
Widget buildSharedLinkRow({required String leading, required String content}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
content,
|
||||
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(leading, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLinkTitle() {
|
||||
if (existingLink != null) {
|
||||
if (existingLink!.type == SharedLinkSource.album) {
|
||||
return buildSharedLinkRow(leading: context.t.public_album, content: existingLink!.title);
|
||||
return Row(
|
||||
children: [
|
||||
const Text('public_album', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
existingLink!.title,
|
||||
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (existingLink!.type == SharedLinkSource.individual) {
|
||||
return buildSharedLinkRow(
|
||||
leading: context.t.shared_link_individual_shared,
|
||||
content: existingLink!.description ?? "--",
|
||||
return Row(
|
||||
children: [
|
||||
const Text('shared_link_individual_shared', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
existingLink!.description ?? "--",
|
||||
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Text(context.t.create_link_to_share_description, style: const TextStyle(fontWeight: FontWeight.bold));
|
||||
return const Text("create_link_to_share_description", style: TextStyle(fontWeight: FontWeight.bold)).tr();
|
||||
}
|
||||
|
||||
Widget buildDescriptionField() {
|
||||
return TextField(
|
||||
controller: descriptionController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
focusNode: descriptionFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.t.description,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
labelText: 'description'.tr(),
|
||||
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: context.t.shared_link_edit_description_hint,
|
||||
hintText: 'shared_link_edit_description_hint'.tr(),
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
|
||||
),
|
||||
onTapOutside: (_) => descriptionFocusNode.unfocus(),
|
||||
);
|
||||
@@ -112,14 +96,16 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
Widget buildPasswordField() {
|
||||
return TextField(
|
||||
controller: passwordController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.t.password,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
labelText: 'password'.tr(),
|
||||
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: context.t.shared_link_edit_password_hint,
|
||||
hintText: 'shared_link_edit_password_hint'.tr(),
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -127,16 +113,18 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
Widget buildSlugField() {
|
||||
return TextField(
|
||||
controller: slugController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
focusNode: slugFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: slugController.text.isNotEmpty ? context.t.custom_url : null,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
labelText: 'custom_url'.tr(),
|
||||
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: context.t.custom_url,
|
||||
prefixText: slugController.text.isNotEmpty ? '/s/' : null,
|
||||
prefixStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
hintText: 'custom_url'.tr(),
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
|
||||
),
|
||||
onTapOutside: (_) => slugFocusNode.unfocus(),
|
||||
);
|
||||
@@ -145,182 +133,145 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
Widget buildShowMetaButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showMetadata.value,
|
||||
onChanged: (value) => showMetadata.value = value,
|
||||
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
|
||||
activeThumbColor: colorScheme.primary,
|
||||
dense: true,
|
||||
title: Text(
|
||||
context.t.show_metadata,
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAllowDownloadButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: allowDownload.value,
|
||||
onChanged: (value) => allowDownload.value = value,
|
||||
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
|
||||
activeThumbColor: colorScheme.primary,
|
||||
dense: true,
|
||||
title: Text(
|
||||
context.t.allow_public_user_to_download,
|
||||
"allow_public_user_to_download",
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAllowUploadButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: allowUpload.value,
|
||||
onChanged: (value) => allowUpload.value = value,
|
||||
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
|
||||
activeThumbColor: colorScheme.primary,
|
||||
dense: true,
|
||||
title: Text(
|
||||
context.t.allow_public_user_to_upload,
|
||||
"allow_public_user_to_upload",
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
String formatDateTime(DateTime dateTime) => DateFormat.yMMMd(context.locale.toString()).add_Hm().format(dateTime);
|
||||
|
||||
DateTime? getExpiresAtFromPreset(Duration preset) => preset == Duration.zero ? null : DateTime.now().add(preset);
|
||||
|
||||
Future<void> selectDate() async {
|
||||
final today = DateTime.now();
|
||||
final safeInitialDate = expiryAfter.value ?? today.add(const Duration(days: 7));
|
||||
final initialDate = safeInitialDate.isBefore(today) ? today : safeInitialDate;
|
||||
|
||||
final selectedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initialDate,
|
||||
firstDate: today,
|
||||
lastDate: today.add(const Duration(days: maxFutureDate)),
|
||||
Widget buildEditExpiryButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: editExpiry.value,
|
||||
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
|
||||
activeThumbColor: colorScheme.primary,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"change_expiration_time",
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
|
||||
if (selectedDate != null && context.mounted) {
|
||||
final isToday =
|
||||
selectedDate.year == today.year && selectedDate.month == today.month && selectedDate.day == today.day;
|
||||
final initialTime = isToday ? TimeOfDay.fromDateTime(today) : const TimeOfDay(hour: 12, minute: 0);
|
||||
|
||||
final selectedTime = await showTimePicker(context: context, initialTime: initialTime);
|
||||
|
||||
if (selectedTime != null) {
|
||||
final now = DateTime.now();
|
||||
var finalDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
selectedTime.hour,
|
||||
selectedTime.minute,
|
||||
);
|
||||
|
||||
if (finalDateTime.isBefore(now) && isToday) {
|
||||
finalDateTime = now;
|
||||
}
|
||||
|
||||
selectedPresetIndex.value = null;
|
||||
expiryAfter.value = finalDateTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildExpiryAfterButton() {
|
||||
return ExpansionTile(
|
||||
title: Text(
|
||||
context.t.expire_after,
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
expiryAfter.value == null ? context.t.shared_link_expires_never : formatDateTime(expiryAfter.value!),
|
||||
style: TextStyle(color: themeData.colorScheme.primary),
|
||||
),
|
||||
return DropdownMenu(
|
||||
label: Text(
|
||||
"expire_after",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
).tr(),
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
width: context.width - 40,
|
||||
initialSelection: expiryAfter.value,
|
||||
enabled: newShareLink.value.isEmpty && (existingLink == null || editExpiry.value),
|
||||
onSelected: (value) {
|
||||
expiryAfter.value = value!;
|
||||
},
|
||||
dropdownMenuEntries: [
|
||||
DropdownMenuEntry(value: 0, label: "never".tr()),
|
||||
DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "shared_link_edit_expire_after_option_minutes".tr(namedArgs: {'count': "30"}),
|
||||
),
|
||||
DropdownMenuEntry(value: 60, label: "shared_link_edit_expire_after_option_hour".tr()),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 6,
|
||||
label: "shared_link_edit_expire_after_option_hours".tr(namedArgs: {'count': "6"}),
|
||||
),
|
||||
DropdownMenuEntry(value: 60 * 24, label: "shared_link_edit_expire_after_option_day".tr()),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 7,
|
||||
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "7"}),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 30,
|
||||
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "30"}),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 30 * 3,
|
||||
label: "shared_link_edit_expire_after_option_months".tr(namedArgs: {'count': "3"}),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 30 * 12,
|
||||
label: "shared_link_edit_expire_after_option_year".tr(namedArgs: {'count': "1"}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void copyLinkToClipboard() {
|
||||
Clipboard.setData(ClipboardData(text: newShareLink.value)).then((_) {
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"shared_link_clipboard_copied_massage",
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
).tr(),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildNewLinkField() {
|
||||
return Column(
|
||||
children: [
|
||||
const Padding(padding: EdgeInsets.only(top: 20, bottom: 20), child: Divider()),
|
||||
TextFormField(
|
||||
readOnly: true,
|
||||
initialValue: newShareLink.value,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
|
||||
suffixIcon: IconButton(onPressed: copyLinkToClipboard, icon: const Icon(Icons.copy)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(expiryPresets.length, (index) {
|
||||
final preset = expiryPresets[index];
|
||||
return ChoiceChip(
|
||||
label: Text(preset.$2),
|
||||
selected: selectedPresetIndex.value == index,
|
||||
onSelected: (_) {
|
||||
selectedPresetIndex.value = index;
|
||||
expiryAfter.value = getExpiresAtFromPreset(preset.$1);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (expiryAfter.value != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: selectDate,
|
||||
icon: const Icon(Icons.edit_calendar),
|
||||
label: Text(context.t.edit_date_and_time),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.maybePop();
|
||||
},
|
||||
child: const Text("done", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> copyToClipboard(String link) async {
|
||||
await Clipboard.setData(ClipboardData(text: link));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.t.shared_link_clipboard_copied_massage,
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
DateTime calculateExpiry() {
|
||||
return DateTime.now().add(Duration(minutes: expiryAfter.value));
|
||||
}
|
||||
|
||||
Widget buildLinkCopyField(String link) {
|
||||
return TextFormField(
|
||||
readOnly: true,
|
||||
onTap: () => copyToClipboard(link),
|
||||
initialValue: link,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
|
||||
suffixIcon: IconButton(onPressed: () => Share.share(link), icon: const Icon(Icons.share)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildNewLinkReadyScreen() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_link, size: 100, color: themeData.colorScheme.primary),
|
||||
const SizedBox(height: 20),
|
||||
buildLinkCopyField(newShareLink.value),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.maybePop(),
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(context.t.done, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? calculateExpiry() => expiryAfter.value;
|
||||
|
||||
Future<void> handleNewLink() async {
|
||||
final newLink = await ref
|
||||
.read(sharedLinkServiceProvider)
|
||||
@@ -333,30 +284,30 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
description: descriptionController.text.isEmpty ? null : descriptionController.text,
|
||||
password: passwordController.text.isEmpty ? null : passwordController.text,
|
||||
slug: slugController.text.isEmpty ? null : slugController.text,
|
||||
expiresAt: calculateExpiry()?.toUtc(),
|
||||
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
|
||||
await ref.read(serverInfoProvider.notifier).getServerConfig();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
|
||||
|
||||
final serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
|
||||
var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
|
||||
if (serverUrl != null && !serverUrl.endsWith('/')) {
|
||||
serverUrl += '/';
|
||||
}
|
||||
|
||||
if (newLink != null) {
|
||||
newShareLink.value = buildSharedLinkUrl(baseUrl: serverUrl, slug: newLink.slug, key: newLink.key) ?? '';
|
||||
await copyToClipboard(newShareLink.value);
|
||||
} else {
|
||||
if (newLink != null && serverUrl != null) {
|
||||
final hasSlug = newLink.slug?.isNotEmpty == true;
|
||||
final urlPath = hasSlug ? newLink.slug : newLink.key;
|
||||
final basePath = hasSlug ? 's' : 'share';
|
||||
newShareLink.value = "$serverUrl$basePath/$urlPath";
|
||||
copyLinkToClipboard();
|
||||
} else if (newLink == null) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
msg: context.t.shared_link_create_error,
|
||||
msg: 'shared_link_create_error'.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -397,9 +348,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
slug = existingLink!.slug;
|
||||
}
|
||||
|
||||
final newExpiry = expiryAfter.value;
|
||||
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
|
||||
expiry = newExpiry;
|
||||
if (editExpiry.value) {
|
||||
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
|
||||
changeExpiry = true;
|
||||
}
|
||||
|
||||
@@ -413,115 +363,69 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
description: desc,
|
||||
password: password,
|
||||
slug: slug,
|
||||
expiresAt: expiry?.toUtc(),
|
||||
expiresAt: expiry,
|
||||
changeExpiry: changeExpiry,
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
await context.maybePop();
|
||||
}
|
||||
|
||||
Future<void> handleDeleteLink() async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) => ConfirmDialog(
|
||||
title: "delete_shared_link_dialog_title",
|
||||
content: "confirm_delete_shared_link",
|
||||
onOk: () async {
|
||||
await ref.read(sharedLinkServiceProvider).deleteSharedLink(existingLink!.id);
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
if (context.mounted) {
|
||||
await context.maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(existingLink == null ? context.t.create_link_to_share : context.t.edit_link),
|
||||
title: Text(existingLink == null ? "create_link_to_share" : "edit_link").tr(),
|
||||
elevation: 0,
|
||||
leading: const CloseButton(),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: newShareLink.value.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
buildLinkTitle(),
|
||||
if (existingLink != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
buildLinkCopyField(
|
||||
buildSharedLinkUrl(
|
||||
baseUrl: displayServerUrl,
|
||||
slug: existingLink!.slug,
|
||||
key: existingLink!.key,
|
||||
) ??
|
||||
'',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
buildDescriptionField(),
|
||||
const SizedBox(height: 16),
|
||||
buildPasswordField(),
|
||||
const SizedBox(height: 16),
|
||||
buildSlugField(),
|
||||
const SizedBox(height: 16),
|
||||
buildShowMetaButton(),
|
||||
const SizedBox(height: 16),
|
||||
buildAllowDownloadButton(),
|
||||
const SizedBox(height: 16),
|
||||
buildAllowUploadButton(),
|
||||
const SizedBox(height: 16),
|
||||
buildExpiryAfterButton(),
|
||||
const SizedBox(height: 24),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (existingLink != null)
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: themeData.colorScheme.error,
|
||||
side: BorderSide(color: themeData.colorScheme.error),
|
||||
),
|
||||
onPressed: handleDeleteLink,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
context.t.delete,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: existingLink != null ? handleEditLink : handleNewLink,
|
||||
label: Text(
|
||||
existingLink != null ? context.t.shared_link_edit_submit_button : context.t.create_link,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildShowMetaButton(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildAllowDownloadButton(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: 20, bottom: 20),
|
||||
child: buildAllowUploadButton(),
|
||||
),
|
||||
if (existingLink != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildEditExpiryButton(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildExpiryAfterButton(),
|
||||
),
|
||||
if (newShareLink.value.isEmpty)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: padding + 10, bottom: padding),
|
||||
child: ElevatedButton(
|
||||
onPressed: existingLink != null ? handleEditLink : handleNewLink,
|
||||
child: Text(
|
||||
existingLink != null ? "shared_link_edit_submit_button" : "create_link",
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(child: buildNewLinkReadyScreen()),
|
||||
),
|
||||
if (newShareLink.value.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildNewLinkField(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PermissionStatus) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : PermissionStatus.values[value];
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionApi {
|
||||
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as PermissionStatus;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
||||
|
||||
final scrollView = CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
ImmichSliverAppBar(
|
||||
snap: false,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftAssetSelectionTimelinePage extends ConsumerWidget {
|
||||
@@ -21,13 +22,17 @@ class DriftAssetSelectionTimelinePage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
timelineServiceProvider.overrideWith((ref) {
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) {
|
||||
throw Exception('User must be logged in to access asset selection timeline');
|
||||
}
|
||||
|
||||
final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(user.id);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: const Timeline(showStorageIndicator: true),
|
||||
child: const Timeline(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,14 +179,17 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
}
|
||||
|
||||
final album = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.createAlbumWithAssets(
|
||||
.watch(remoteAlbumProvider.notifier)
|
||||
.createAlbum(
|
||||
title: title,
|
||||
description: albumDescriptionController.text.trim(),
|
||||
assets: selectedAssets,
|
||||
assetIds: selectedAssets.map((asset) {
|
||||
final remoteAsset = asset as RemoteAsset;
|
||||
return remoteAsset.id;
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
if (album != null && context.mounted) {
|
||||
if (album != null) {
|
||||
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/pending_uploads_banner.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
@@ -40,8 +39,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
}
|
||||
|
||||
Future<void> addAssets(BuildContext context) async {
|
||||
final notifier = ref.read(remoteAlbumProvider.notifier);
|
||||
final albumAssets = await notifier.getAssets(_album.id);
|
||||
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id);
|
||||
|
||||
final newAssets = await context.pushRoute<Set<BaseAsset>>(
|
||||
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
|
||||
@@ -51,9 +49,17 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final added = await notifier.addAssetsToAlbum(_album.id, newAssets);
|
||||
final added = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.addAssets(
|
||||
_album.id,
|
||||
newAssets.map((asset) {
|
||||
final remoteAsset = asset as RemoteAsset;
|
||||
return remoteAsset.id;
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
if (added > 0 && context.mounted) {
|
||||
if (added > 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}),
|
||||
@@ -180,7 +186,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
currentRemoteAlbumScopedProvider.overrideWithValue(_album),
|
||||
],
|
||||
child: Timeline(
|
||||
topSliverWidget: PendingUploadsBanner(albumId: _album.id),
|
||||
appBar: RemoteAlbumSliverAppBar(
|
||||
icon: Icons.photo_album_outlined,
|
||||
kebabMenu: _AlbumKebabMenu(
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftSlideshowPage extends ConsumerStatefulWidget {
|
||||
final TimelineService timeline;
|
||||
|
||||
const DriftSlideshowPage({super.key, required this.timeline});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
|
||||
}
|
||||
|
||||
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
late SlideshowConfig _config;
|
||||
late final PageController _pageController;
|
||||
late final Stopwatch _stopwatch;
|
||||
late Timer _timer;
|
||||
late int _index;
|
||||
late int _nextIndex;
|
||||
bool _paused = false;
|
||||
bool _showAppBar = false;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_config = ref.read(appConfigProvider.select((s) => s.slideshow));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
|
||||
_pageController = PageController(initialPage: _index);
|
||||
_stopwatch = Stopwatch();
|
||||
_createTimer();
|
||||
_updateNextIndex();
|
||||
ref.listenManual(appConfigProvider.select((s) => s.slideshow), _onConfigChanged);
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
unawaited(WakelockPlus.enable());
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_pageController.dispose();
|
||||
unawaited(WakelockPlus.disable());
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _play() {
|
||||
final asset = widget.timeline.getAssetSafe(_index)!;
|
||||
|
||||
if (asset.isImage) {
|
||||
_createTimer();
|
||||
} else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).play();
|
||||
} else {
|
||||
_nextPage();
|
||||
}
|
||||
|
||||
_updateNextIndex();
|
||||
|
||||
setState(() {
|
||||
_paused = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _pause() {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
|
||||
final asset = widget.timeline.getAssetSafe(_index)!;
|
||||
|
||||
if (!asset.isImage) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).pause();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_paused = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _onConfigChanged(SlideshowConfig? previous, SlideshowConfig next) {
|
||||
if (_config == next) {
|
||||
return;
|
||||
}
|
||||
|
||||
final durationChanged = _config.duration != next.duration;
|
||||
_config = next;
|
||||
_updateNextIndex();
|
||||
|
||||
final asset = widget.timeline.getAssetSafe(_index);
|
||||
if (durationChanged && !_paused && asset?.isImage == true) {
|
||||
_timer.cancel();
|
||||
_createTimer();
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _updateNextIndex() {
|
||||
_nextIndex = switch (_config.direction) {
|
||||
SlideshowDirection.forward => _index + 1,
|
||||
SlideshowDirection.backward => _index - 1,
|
||||
SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!,
|
||||
};
|
||||
|
||||
if (!widget.timeline.hasRange(_nextIndex, 1)) {
|
||||
widget.timeline.preloadAssets(_nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void _nextPage() async {
|
||||
if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) {
|
||||
if (_config.repeat) {
|
||||
final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
|
||||
await widget.timeline.preloadAssets(wrapped);
|
||||
_pageController.jumpToPage(wrapped);
|
||||
} else {
|
||||
setState(() {
|
||||
_paused = true;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!widget.timeline.hasRange(_nextIndex, 1)) {
|
||||
await widget.timeline.preloadAssets(_nextIndex);
|
||||
}
|
||||
|
||||
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
|
||||
_pageController.jumpToPage(_nextIndex);
|
||||
} else {
|
||||
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
|
||||
}
|
||||
}
|
||||
|
||||
void _createTimer() {
|
||||
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () {
|
||||
_stopwatch.stop();
|
||||
_stopwatch.reset();
|
||||
_nextPage();
|
||||
});
|
||||
|
||||
_stopwatch.start();
|
||||
}
|
||||
|
||||
void _pageChanged(int page) {
|
||||
final asset = widget.timeline.getAssetSafe(page)!;
|
||||
|
||||
setState(() {
|
||||
_index = page;
|
||||
|
||||
if (!asset.isImage) {
|
||||
_paused = false;
|
||||
}
|
||||
});
|
||||
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_stopwatch.reset();
|
||||
|
||||
if (!_paused && asset.isImage) {
|
||||
_createTimer();
|
||||
}
|
||||
|
||||
_updateNextIndex();
|
||||
}
|
||||
|
||||
void _onTapUp() async {
|
||||
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_showAppBar = !_showAppBar;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _getProgressBar(BuildContext context) {
|
||||
final asset = widget.timeline.getAssetSafe(_index);
|
||||
|
||||
if (asset == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
key: Key(_index.toString()),
|
||||
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value:
|
||||
ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
|
||||
asset.duration.inMilliseconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _getBlur(BuildContext context, int index) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.2)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getPhotoView(BuildContext context, int index) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
final scale = _config.look == SlideshowLook.cover
|
||||
? PhotoViewComputedScale.covered
|
||||
: PhotoViewComputedScale.contained;
|
||||
final isCurrent = _index == index;
|
||||
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
|
||||
|
||||
if (asset.isImage) {
|
||||
final zoomOut = index % 2 == 1;
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
tween: Tween<double>(
|
||||
begin: progress,
|
||||
end: _paused
|
||||
? progress
|
||||
: zoomOut
|
||||
? 0.0
|
||||
: 1.0,
|
||||
),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale * (1.0 + value / 10.0),
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
|
||||
final position = ref.read(videoPlayerProvider(asset.heroTag)).position;
|
||||
|
||||
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
|
||||
_nextPage();
|
||||
} else if (status == VideoPlaybackStatus.playing) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false);
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
disableScaleGestures: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale,
|
||||
child: NativeVideoViewer(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showAppBar,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showAppBar ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: Column(
|
||||
children: [
|
||||
AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text("slideshow".t(context: context)),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _paused ? _play : _pause,
|
||||
icon: Icon(_paused ? Icons.play_arrow : Icons.pause),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
_pause();
|
||||
context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer));
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
_getProgressBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.black,
|
||||
body: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const FastClampingScrollPhysics(),
|
||||
itemCount: widget.timeline.totalAssets,
|
||||
onPageChanged: _pageChanged,
|
||||
itemBuilder: (context, index) => Stack(
|
||||
children: [
|
||||
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
|
||||
_getPhotoView(context, index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
expanded: true,
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
|
||||
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -35,11 +35,10 @@ class BaseActionButton extends ConsumerWidget {
|
||||
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) {
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||
@@ -47,21 +46,17 @@ class BaseActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (menuItem) {
|
||||
final iconColor = this.iconColor;
|
||||
final theme = context.themeData;
|
||||
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
leadingIcon: Icon(iconData, color: iconColor, size: 20),
|
||||
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
||||
leadingIcon: Icon(iconData, color: effectiveIconColor),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
|
||||
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: MaterialButton(
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class BulkTagAssetsActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const BulkTagAssetsActionButton({super.key, required this.source});
|
||||
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
final result = await ref.read(actionProvider.notifier).tagAssets(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success
|
||||
? 'tagged_assets'.t(context: context, args: {'count': result.count.toString()})
|
||||
: 'errors.failed_to_tag_assets'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.sell_outlined,
|
||||
label: "control_bottom_app_bar_add_tags".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-9
@@ -18,15 +18,8 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
final bool useShortLabel;
|
||||
|
||||
const DeletePermanentActionButton({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
this.useShortLabel = false,
|
||||
});
|
||||
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
@@ -71,7 +64,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
return BaseActionButton(
|
||||
maxWidth: 110.0,
|
||||
iconData: Icons.delete_forever,
|
||||
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
|
||||
label: "delete_permanently".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RestoreActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.history_rounded,
|
||||
label: 'restore'.t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SlideshowActionButton extends ConsumerWidget {
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.slideshow,
|
||||
label: "slideshow".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,15 @@ 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/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/album_filter.utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -58,11 +58,19 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final albumConfig = ref.read(metadataProvider).appConfig.album;
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
|
||||
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
|
||||
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
|
||||
|
||||
final albumSortMode = AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == savedSortMode,
|
||||
orElse: () => AlbumSortMode.lastModified,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
sort = AlbumSort(mode: albumConfig.sortMode, isReverse: albumConfig.isReverse);
|
||||
isGrid = albumConfig.isGrid;
|
||||
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
|
||||
isGrid = savedIsGrid;
|
||||
});
|
||||
|
||||
ref.read(remoteAlbumProvider.notifier).refresh();
|
||||
@@ -94,7 +102,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
setState(() {
|
||||
isGrid = !isGrid;
|
||||
});
|
||||
ref.read(metadataProvider).write(MetadataKey.albumIsGrid, isGrid);
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
|
||||
}
|
||||
|
||||
void changeFilter(QuickFilterMode mode) {
|
||||
@@ -110,9 +118,9 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
this.sort = sort;
|
||||
});
|
||||
|
||||
final metadata = ref.read(metadataProvider);
|
||||
await metadata.write(MetadataKey.albumSortMode, sort.mode);
|
||||
await metadata.write(MetadataKey.albumIsReverse, sort.isReverse);
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
|
||||
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
|
||||
|
||||
await sortAlbums();
|
||||
}
|
||||
|
||||
@@ -1,252 +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/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
|
||||
/// Pinned banner sliver that surfaces in-flight album uploads directly under
|
||||
/// the album app bar. Renders nothing while the queue is empty. Tapping the
|
||||
/// banner opens a bottom sheet with per-asset progress.
|
||||
class PendingUploadsBanner extends ConsumerWidget {
|
||||
static const double _height = 52;
|
||||
|
||||
final String albumId;
|
||||
|
||||
const PendingUploadsBanner({super.key, required this.albumId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pending = ref.watch(pendingAlbumUploadsProvider(albumId));
|
||||
if (pending.isEmpty) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
final hasFailures = pending.any((p) => p.failed);
|
||||
final clamped = pending.map((p) => p.progress.clamp(0.0, 1.0)).toList(growable: false);
|
||||
final overallProgress = clamped.isEmpty ? 0.0 : clamped.reduce((a, b) => a + b) / clamped.length;
|
||||
final isIndeterminate = overallProgress <= 0.0;
|
||||
|
||||
return SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _PendingUploadsBannerDelegate(
|
||||
height: _height,
|
||||
child: _PendingUploadsBannerContent(
|
||||
albumId: albumId,
|
||||
previewAsset: pending.first.asset,
|
||||
count: pending.length,
|
||||
overallProgress: overallProgress,
|
||||
isIndeterminate: isIndeterminate,
|
||||
hasFailures: hasFailures,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _openSheet(BuildContext context, String albumId) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => _PendingUploadsSheet(albumId: albumId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingUploadsBannerDelegate extends SliverPersistentHeaderDelegate {
|
||||
final double height;
|
||||
final Widget child;
|
||||
|
||||
const _PendingUploadsBannerDelegate({required this.height, required this.child});
|
||||
|
||||
@override
|
||||
double get minExtent => height;
|
||||
|
||||
@override
|
||||
double get maxExtent => height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant _PendingUploadsBannerDelegate oldDelegate) =>
|
||||
height != oldDelegate.height || child != oldDelegate.child;
|
||||
}
|
||||
|
||||
class _PendingUploadsBannerContent extends StatelessWidget {
|
||||
final String albumId;
|
||||
final BaseAsset previewAsset;
|
||||
final int count;
|
||||
final double overallProgress;
|
||||
final bool isIndeterminate;
|
||||
final bool hasFailures;
|
||||
|
||||
const _PendingUploadsBannerContent({
|
||||
required this.albumId,
|
||||
required this.previewAsset,
|
||||
required this.count,
|
||||
required this.overallProgress,
|
||||
required this.isIndeterminate,
|
||||
required this.hasFailures,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percentLabel = isIndeterminate ? '' : ' · ${(overallProgress * 100).toInt()}%';
|
||||
return Material(
|
||||
color: hasFailures ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainerHigh,
|
||||
child: InkWell(
|
||||
onTap: () => PendingUploadsBanner._openSheet(context, albumId),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: SizedBox(width: 32, height: 32, child: Thumbnail.fromAsset(asset: previewAsset)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${'uploading'.t(context: context)} $count$percentLabel',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
if (hasFailures)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Icon(Icons.error_outline, color: context.colorScheme.error, size: 20),
|
||||
),
|
||||
Icon(Icons.chevron_right_rounded, color: context.colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 3,
|
||||
child: LinearProgressIndicator(
|
||||
value: isIndeterminate ? null : overallProgress,
|
||||
backgroundColor: context.colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
hasFailures ? context.colorScheme.error : context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingUploadsSheet extends ConsumerWidget {
|
||||
final String albumId;
|
||||
|
||||
const _PendingUploadsSheet({required this.albumId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pending = ref.watch(pendingAlbumUploadsProvider(albumId));
|
||||
|
||||
// Auto-dismiss when the queue empties.
|
||||
if (pending.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final failedCount = pending.where((p) => p.failed).length;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${'uploading'.t(context: context)} (${pending.length})',
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (failedCount > 0)
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(),
|
||||
icon: const Icon(Icons.clear_rounded, size: 18),
|
||||
label: Text('clear_failed_count'.t(context: context, args: {'count': failedCount})),
|
||||
style: TextButton.styleFrom(foregroundColor: context.colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: pending.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, index) => _PendingUploadTile(entry: pending[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingUploadTile extends StatelessWidget {
|
||||
final PendingAlbumUpload entry;
|
||||
|
||||
const _PendingUploadTile({required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Thumbnail.fromAsset(asset: entry.asset),
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: entry.failed ? Colors.red.withValues(alpha: 0.6) : Colors.black54,
|
||||
child: Center(
|
||||
child: entry.failed
|
||||
? const Icon(Icons.error_outline, color: Colors.white, size: 28)
|
||||
: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
value: entry.progress > 0 ? entry.progress : null,
|
||||
strokeWidth: 2.5,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,11 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widg
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
@@ -56,13 +57,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_DragIntent _dragIntent = _DragIntent.none;
|
||||
Drag? _drag;
|
||||
|
||||
BaseAsset? _asset;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) {
|
||||
return;
|
||||
@@ -74,14 +72,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AssetPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.index != widget.index) {
|
||||
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
@@ -241,7 +231,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final tapToNavigate = ref.read(metadataProvider).appConfig.viewer.tapToNavigate;
|
||||
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
|
||||
if (!tapToNavigate) {
|
||||
_viewer.toggleControls();
|
||||
return;
|
||||
@@ -394,7 +384,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
|
||||
final asset = _asset;
|
||||
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
if (asset == null) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
@@ -2,19 +2,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_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';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -37,31 +33,23 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
|
||||
if (isOwner) ...[
|
||||
if (asset.isLocalOnly)
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
else if (asset.isTrashed)
|
||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
||||
else
|
||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
asset.isLocalOnly
|
||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
|
||||
class MotionPhotoPlayButton extends ConsumerWidget {
|
||||
const MotionPhotoPlayButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
|
||||
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
|
||||
final showControls = ref.watch(assetViewerProvider.select((state) => state.showingControls));
|
||||
final isShowingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
|
||||
if (asset == null || !asset.isMotionPhoto || isShowingDetails) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !showControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Center(
|
||||
child: _MotionButton(
|
||||
isPlaying: isPlaying,
|
||||
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MotionButton extends StatelessWidget {
|
||||
final bool isPlaying;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _MotionButton({required this.isPlaying, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.grey[900]!.withValues(alpha: 0.4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
CurrentPlatform.isAndroid ? 'motion'.t(context: context) : 'live'.t(context: context),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,21 @@ import 'dart:async';
|
||||
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/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/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
|
||||
@@ -128,7 +132,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final isOriginalVideo = ref.read(metadataProvider).appConfig.viewer.loadOriginalVideo;
|
||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||
@@ -161,7 +165,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
return;
|
||||
}
|
||||
|
||||
final autoPlayVideo = ref.read(metadataProvider).appConfig.viewer.autoPlayVideo;
|
||||
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
||||
if (autoPlayVideo || widget.asset.isMotionPhoto) {
|
||||
await _notifier.play();
|
||||
}
|
||||
@@ -212,7 +216,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
}
|
||||
|
||||
await _notifier.load(source);
|
||||
final loopVideo = ref.read(metadataProvider).appConfig.viewer.loopVideo;
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||
await _notifier.setVolume(1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
@@ -11,13 +10,11 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
|
||||
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/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/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/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
|
||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
const ViewerTopAppBar({super.key});
|
||||
@@ -98,17 +95,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: SizedBox(
|
||||
height: preferredSize.height,
|
||||
child: SizedBox.square(
|
||||
child: Theme(
|
||||
data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)),
|
||||
child: NavigationToolbar(
|
||||
centerMiddle: true,
|
||||
leading: const _AppBarBackButton(),
|
||||
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
|
||||
trailing: !showingDetails && !isReadonlyModeEnabled
|
||||
? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
const _AppBarBackButton(),
|
||||
const Spacer(),
|
||||
if (!showingDetails && !isReadonlyModeEnabled)
|
||||
if (isInLockedView) ...lockedViewActions else ...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -143,32 +139,3 @@ class _AppBarBackButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetInfoTitle extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const _AssetInfoTitle({required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
DateTime dateTime = asset.createdAt.toLocal();
|
||||
final currentYear = DateTime.now().year;
|
||||
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
|
||||
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
(dateTime, _) = applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo.timeZone);
|
||||
}
|
||||
|
||||
final isCurrentYear = dateTime.year == currentYear;
|
||||
final dateFormatted = isCurrentYear ? DateFormat.MMMd().format(dateTime) : DateFormat.yMMMd().format(dateTime);
|
||||
final timeFormatted = DateFormat.jm().format(dateTime);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(dateFormatted, style: context.textTheme.labelLarge?.copyWith(color: Colors.white)),
|
||||
Text(timeFormatted, style: context.textTheme.labelMedium?.copyWith(color: Colors.white70)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/setting.model.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/bulk_tag_assets_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';
|
||||
@@ -27,7 +26,6 @@ import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.d
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -59,9 +57,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
||||
final tagsEnabled = ref.watch(
|
||||
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
||||
);
|
||||
|
||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||
final selectedAssets = multiselect.selectedAssets;
|
||||
@@ -119,7 +114,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const FavoriteActionButton(source: ActionSource.timeline),
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -3,8 +3,9 @@ import 'dart:ui' as ui;
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
@@ -188,6 +189,4 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
||||
}
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
asset.hasLocal &&
|
||||
(!asset.hasRemote || !MetadataRepository.instance.appConfig.image.preferRemote) &&
|
||||
!asset.isEdited;
|
||||
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
@@ -104,7 +105,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return;
|
||||
}
|
||||
|
||||
final loadOriginal = MetadataRepository.instance.appConfig.image.loadOriginal;
|
||||
final loadOriginal = Store.get(StoreKey.loadOriginal, false);
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
var request = this.request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
@@ -122,7 +123,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
edited: key.edited,
|
||||
),
|
||||
);
|
||||
final loadOriginal = assetType == AssetType.image && MetadataRepository.instance.appConfig.image.loadOriginal;
|
||||
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
|
||||
|
||||
if (!loadOriginal) {
|
||||
|
||||
@@ -120,9 +120,6 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
},
|
||||
flightShuttleBuilder: (context, animation, direction, from, to) {
|
||||
void animationStatusListener(AnimationStatus status) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse;
|
||||
if (_hideIndicators != heroInFlight) {
|
||||
setState(() => _hideIndicators = heroInFlight);
|
||||
|
||||
@@ -242,11 +242,7 @@ class _AssetTileWidget extends ConsumerWidget {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Iterate with `==` instead of `Set.contains` because `RemoteAsset.hashCode`
|
||||
// includes `localId` while `==` does not — so the same server asset can
|
||||
// hash to a different bucket when its `localId` differs (e.g., album-fetched
|
||||
// copy has localId=null, merged-timeline copy has it populated).
|
||||
return lockSelectionAssets.any((a) => a == asset);
|
||||
return lockSelectionAssets.contains(asset);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -64,32 +64,36 @@ class Timeline extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -375,126 +379,121 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
},
|
||||
child: PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: asyncSegments.widgetWhen(
|
||||
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||
? 200
|
||||
: 0;
|
||||
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
||||
child: asyncSegments.widgetWhen(
|
||||
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||
? 200
|
||||
: 0;
|
||||
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
||||
|
||||
const bottomSheetOpenModifier = 120.0;
|
||||
final contentBottomPadding =
|
||||
context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
|
||||
const bottomSheetOpenModifier = 120.0;
|
||||
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
|
||||
|
||||
final grid = CustomScrollView(
|
||||
primary: true,
|
||||
physics: _scrollPhysics,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
if (index >= childCount) {
|
||||
return null;
|
||||
}
|
||||
final segment = segments.findByIndex(index);
|
||||
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
// We add repaint boundary around tiles, so skip the auto boundaries
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
),
|
||||
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
|
||||
],
|
||||
);
|
||||
|
||||
final Widget timeline;
|
||||
if (widget.withScrubber) {
|
||||
timeline = Scrubber(
|
||||
snapToMonth: widget.snapToMonth,
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: topPadding,
|
||||
bottomPadding: scrubberBottomPadding,
|
||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||
hasAppBar: widget.appBar != null,
|
||||
child: grid,
|
||||
);
|
||||
} else {
|
||||
timeline = grid;
|
||||
}
|
||||
|
||||
return RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||
onScroll: _dragScroll,
|
||||
onScrollStart: () {
|
||||
// Minimize the bottom sheet when drag selection starts
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
final grid = CustomScrollView(
|
||||
primary: true,
|
||||
physics: _scrollPhysics,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
if (index >= childCount) {
|
||||
return null;
|
||||
}
|
||||
final segment = segments.findByIndex(index);
|
||||
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
|
||||
},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
timeline,
|
||||
if (isBottomWidgetVisible)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
],
|
||||
),
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
// We add repaint boundary around tiles, so skip the auto boundaries
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
|
||||
],
|
||||
);
|
||||
|
||||
final Widget timeline;
|
||||
if (widget.withScrubber) {
|
||||
timeline = Scrubber(
|
||||
snapToMonth: widget.snapToMonth,
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: topPadding,
|
||||
bottomPadding: scrubberBottomPadding,
|
||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||
hasAppBar: widget.appBar != null,
|
||||
child: grid,
|
||||
);
|
||||
} else {
|
||||
timeline = grid;
|
||||
}
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||
onScroll: _dragScroll,
|
||||
onScrollStart: () {
|
||||
// Minimize the bottom sheet when drag selection starts
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
timeline,
|
||||
if (isBottomWidgetVisible)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class PendingAlbumUpload {
|
||||
final LocalAsset asset;
|
||||
final double progress;
|
||||
final bool failed;
|
||||
|
||||
const PendingAlbumUpload({required this.asset, this.progress = 0.0, this.failed = false});
|
||||
|
||||
PendingAlbumUpload copyWith({double? progress, bool? failed}) =>
|
||||
PendingAlbumUpload(asset: asset, progress: progress ?? this.progress, failed: failed ?? this.failed);
|
||||
}
|
||||
|
||||
class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier<List<PendingAlbumUpload>, String> {
|
||||
KeepAliveLink? _keepAliveLink;
|
||||
|
||||
@override
|
||||
List<PendingAlbumUpload> build(String albumId) {
|
||||
ref.onDispose(() {
|
||||
_keepAliveLink?.close();
|
||||
_keepAliveLink = null;
|
||||
});
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
void enqueue(Iterable<LocalAsset> assets) {
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final existingIds = state.map((e) => e.asset.id).toSet();
|
||||
final additions = assets.where((a) => !existingIds.contains(a.id)).map((a) => PendingAlbumUpload(asset: a));
|
||||
state = [...state, ...additions];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void updateProgress(String localAssetId, double progress) {
|
||||
state = [
|
||||
for (final entry in state)
|
||||
if (entry.asset.id == localAssetId) entry.copyWith(progress: progress, failed: false) else entry,
|
||||
];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void markFailed(String localAssetId) {
|
||||
state = [
|
||||
for (final entry in state)
|
||||
if (entry.asset.id == localAssetId) entry.copyWith(failed: true) else entry,
|
||||
];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void markAllFailed() {
|
||||
state = [for (final entry in state) entry.copyWith(failed: true)];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void remove(String localAssetId) {
|
||||
state = state.where((e) => e.asset.id != localAssetId).toList();
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void clearFailed() {
|
||||
state = state.where((e) => !e.failed).toList();
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void _syncKeepAlive() {
|
||||
if (state.isEmpty) {
|
||||
_keepAliveLink?.close();
|
||||
_keepAliveLink = null;
|
||||
} else {
|
||||
_keepAliveLink ??= ref.keepAlive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final pendingAlbumUploadsProvider = NotifierProvider.autoDispose
|
||||
.family<AlbumPendingUploadsNotifier, List<PendingAlbumUpload>, String>(AlbumPendingUploadsNotifier.new);
|
||||
@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
@@ -11,15 +8,15 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
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/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -130,8 +127,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
await _apiService.updateHeaders();
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
|
||||
final customHeaders = Store.tryGet(StoreKey.customHeaders);
|
||||
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
|
||||
|
||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||
@@ -148,6 +144,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
// Due to the flow of the code, this will always happen on first login
|
||||
user = serverUser;
|
||||
await Store.put(StoreKey.deviceId, deviceId);
|
||||
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
||||
}
|
||||
} on ApiException catch (error, stackTrace) {
|
||||
if (error.code == 401) {
|
||||
@@ -179,19 +176,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<void> saveWifiName(String wifiName) async {
|
||||
await _ref.read(metadataProvider).write(MetadataKey.networkPreferredWifiName, wifiName);
|
||||
await Store.put(StoreKey.preferredWifiName, wifiName);
|
||||
}
|
||||
|
||||
Future<void> saveLocalEndpoint(String url) async {
|
||||
await _ref.read(metadataProvider).write(MetadataKey.networkLocalEndpoint, url);
|
||||
await Store.put(StoreKey.localEndpoint, url);
|
||||
}
|
||||
|
||||
String? getSavedWifiName() {
|
||||
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
|
||||
return Store.tryGet(StoreKey.preferredWifiName);
|
||||
}
|
||||
|
||||
String? getSavedLocalEndpoint() {
|
||||
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
|
||||
return Store.tryGet(StoreKey.localEndpoint);
|
||||
}
|
||||
|
||||
/// Returns the current server endpoint (with /api) URL from the store
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
@@ -354,23 +353,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
final count = await _service.tagAssets(ids, context);
|
||||
if (count == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ref.invalidate(tagProvider);
|
||||
return ActionResult(count: count, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to tag assets', error, stack);
|
||||
ref.invalidate(tagProvider);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
|
||||
final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
|
||||
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
|
||||
@@ -34,11 +33,7 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
|
||||
);
|
||||
|
||||
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
|
||||
(ref) => RemoteAlbumService(
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(foregroundUploadServiceProvider),
|
||||
),
|
||||
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
|
||||
dependencies: [remoteAlbumRepository],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
@@ -18,6 +19,8 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||
|
||||
final localImageApi = LocalImageApi();
|
||||
|
||||
final remoteImageApi = RemoteImageApi();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
@@ -8,10 +6,8 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class RemoteAlbumState {
|
||||
@@ -109,46 +105,6 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an album from a heterogeneous asset selection. Already-remote
|
||||
/// assets seed the album immediately; local-only assets are uploaded in the
|
||||
/// background and linked one-by-one as each upload completes.
|
||||
Future<RemoteAlbum?> createAlbumWithAssets({
|
||||
required String title,
|
||||
String? description,
|
||||
Iterable<BaseAsset> assets = const [],
|
||||
}) async {
|
||||
try {
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
throw Exception('User not logged in');
|
||||
}
|
||||
|
||||
final candidates = RemoteAlbumService.categorizeCandidates(assets);
|
||||
final album = await _remoteAlbumService.createAlbum(
|
||||
title: title,
|
||||
owner: currentUser,
|
||||
description: description,
|
||||
assetIds: candidates.remoteAssetIds,
|
||||
);
|
||||
|
||||
state = state.copyWith(albums: [...state.albums, album]);
|
||||
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
unawaited(
|
||||
addAssetsToAlbum(
|
||||
album.id,
|
||||
candidates.localAssetsToUpload,
|
||||
).then<void>((_) {}).catchError((Object _, StackTrace _) {}),
|
||||
);
|
||||
}
|
||||
|
||||
return album;
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to create album with assets', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<RemoteAlbum?> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
@@ -199,65 +155,8 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
return _remoteAlbumService.getAssets(albumId);
|
||||
}
|
||||
|
||||
Future<int> addAssets(String albumId, List<String> assetIds) async {
|
||||
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
|
||||
if (added > 0) {
|
||||
await _refreshAlbumInState(albumId);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/// Adds a heterogeneous asset selection to an album. Already-remote assets
|
||||
/// are linked immediately; local-only assets are queued in
|
||||
/// [pendingAlbumUploadsProvider] (so the album page can show them with
|
||||
/// progress indicators), uploaded, and linked one-by-one as each finishes.
|
||||
Future<int> addAssetsToAlbum(String albumId, Iterable<BaseAsset> assets) async {
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
throw Exception('User not logged in');
|
||||
}
|
||||
|
||||
final candidates = RemoteAlbumService.categorizeCandidates(assets);
|
||||
final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier);
|
||||
pendingNotifier.enqueue(candidates.localAssetsToUpload);
|
||||
|
||||
try {
|
||||
final added = await _remoteAlbumService.addAssetsToAlbum(
|
||||
albumId: albumId,
|
||||
uploader: currentUser,
|
||||
candidates: candidates,
|
||||
uploadCallbacks: UploadCallbacks(
|
||||
onProgress: (localAssetId, _, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
pendingNotifier.updateProgress(localAssetId, progress);
|
||||
},
|
||||
onSuccess: (localAssetId, _) => pendingNotifier.remove(localAssetId),
|
||||
onError: (localAssetId, _) => pendingNotifier.markFailed(localAssetId),
|
||||
),
|
||||
);
|
||||
if (added > 0) {
|
||||
await _refreshAlbumInState(albumId);
|
||||
}
|
||||
return added;
|
||||
} catch (error, stack) {
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
pendingNotifier.markAllFailed();
|
||||
}
|
||||
_logger.severe('Failed to add assets to album $albumId', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads a single album from the local DB and replaces it in [state] so
|
||||
/// that views bound to the album list (counts, thumbnails) reflect the
|
||||
/// latest junction-table changes without a full `refresh()`.
|
||||
Future<void> _refreshAlbumInState(String albumId) async {
|
||||
final updated = await _remoteAlbumService.get(albumId);
|
||||
if (updated == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(albums: state.albums.map((album) => album.id == albumId ? updated : album).toList());
|
||||
Future<int> addAssets(String albumId, List<String> assetIds) {
|
||||
return _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
|
||||
}
|
||||
|
||||
Future<void> addUsers(String albumId, List<String> userIds) {
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
|
||||
|
||||
class TagNotifier extends AsyncNotifier<Set<Tag>> {
|
||||
@override
|
||||
Future<Set<Tag>> build() async {
|
||||
return ref.watch(tagServiceProvider).getAllTags();
|
||||
}
|
||||
|
||||
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
|
||||
return ref.read(tagServiceProvider).bulkTagAssets(assetIds, tagIds);
|
||||
}
|
||||
|
||||
Future<List<Tag>> upsertTags(List<String> tags) async {
|
||||
final upsertedTags = await ref.read(tagServiceProvider).upsertTags(tags);
|
||||
|
||||
state = AsyncValue.data({...?state.valueOrNull, ...upsertedTags});
|
||||
return upsertedTags;
|
||||
final repo = ref.read(tagsApiRepositoryProvider);
|
||||
final allTags = await repo.getAllTags();
|
||||
if (allTags == null) {
|
||||
return {};
|
||||
}
|
||||
return allTags.map((t) => Tag.fromDto(t)).toSet();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart' as pm;
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
@@ -39,3 +42,32 @@ class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
final notificationPermissionProvider = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>(
|
||||
(ref) => NotificationPermissionNotifier(),
|
||||
);
|
||||
|
||||
final batteryOptimizationProvider = AsyncNotifierProvider<BatteryOptimizationNotifier, PermissionStatus>(
|
||||
BatteryOptimizationNotifier.new,
|
||||
);
|
||||
|
||||
class BatteryOptimizationNotifier extends AsyncNotifier<PermissionStatus> {
|
||||
Future<PermissionStatus> getBatteryOptimizationPermission() async {
|
||||
final PermissionStatus status;
|
||||
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations().then((p) => p.toStatus());
|
||||
if (isIgnoring == PermissionStatus.granted) {
|
||||
status = PermissionStatus.granted;
|
||||
} else {
|
||||
status = PermissionStatus.denied;
|
||||
}
|
||||
state = AsyncValue.data(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<PermissionStatus> build() => getBatteryOptimizationPermission();
|
||||
}
|
||||
|
||||
extension on pm.PermissionStatus {
|
||||
PermissionStatus toStatus() => switch (this) {
|
||||
pm.PermissionStatus.granted => PermissionStatus.granted,
|
||||
pm.PermissionStatus.denied => PermissionStatus.denied,
|
||||
pm.PermissionStatus.permanentlyDenied => PermissionStatus.permanentlyDenied,
|
||||
};
|
||||
}
|
||||
@@ -1,40 +1,46 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>(
|
||||
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
|
||||
);
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) => AuthRepository(ref.watch(driftProvider)));
|
||||
|
||||
class AuthRepository {
|
||||
final Drift _drift;
|
||||
final MetadataRepository _metadata;
|
||||
|
||||
const AuthRepository(this._drift, this._metadata);
|
||||
const AuthRepository(this._drift);
|
||||
|
||||
Future<void> clearLocalData() async {
|
||||
await SyncStreamRepository(_drift).reset();
|
||||
}
|
||||
|
||||
bool getEndpointSwitchingFeature() {
|
||||
return _metadata.systemConfig.network.autoEndpointSwitching;
|
||||
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
|
||||
}
|
||||
|
||||
String? getPreferredWifiName() {
|
||||
return _metadata.systemConfig.network.preferredWifiName;
|
||||
return Store.tryGet(StoreKey.preferredWifiName);
|
||||
}
|
||||
|
||||
String? getLocalEndpoint() {
|
||||
return _metadata.systemConfig.network.localEndpoint;
|
||||
return Store.tryGet(StoreKey.localEndpoint);
|
||||
}
|
||||
|
||||
List<AuxilaryEndpoint> getExternalEndpointList() {
|
||||
return _metadata.systemConfig.network.externalEndpointList
|
||||
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
|
||||
.toList();
|
||||
final jsonString = Store.tryGet(StoreKey.externalEndpointList);
|
||||
|
||||
if (jsonString == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> jsonList = jsonDecode(jsonString);
|
||||
final endpointList = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
|
||||
|
||||
return endpointList;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,62 +15,37 @@ class AuthGuard extends AutoRouteGuard {
|
||||
final ApiService _apiService;
|
||||
final AuthService _authService;
|
||||
final _log = Logger("AuthGuard");
|
||||
bool _validateInFlight = false;
|
||||
AuthGuard(this._apiService, this._authService);
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
// Synchronously check for the access token. auto_route awaits async
|
||||
// guards, so we keep this function fully sync and validate the token in
|
||||
// the background — otherwise a slow validateAccessToken() request would
|
||||
// block the route transition for as long as the OS-level HTTP timeout.
|
||||
try {
|
||||
Store.get(StoreKey.accessToken);
|
||||
} on StoreKeyNotFoundException catch (_) {
|
||||
_log.warning('No access token in the store.');
|
||||
resolver.next(false);
|
||||
unawaited(router.replaceAll([const LoginRoute()]));
|
||||
return;
|
||||
}
|
||||
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
||||
resolver.next(true);
|
||||
unawaited(_validateAccessTokenInBackground(router));
|
||||
}
|
||||
|
||||
Future<void> _validateAccessTokenInBackground(StackRouter router) async {
|
||||
if (_validateInFlight) {
|
||||
return;
|
||||
}
|
||||
final token = Store.tryGet(StoreKey.accessToken);
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
_validateInFlight = true;
|
||||
try {
|
||||
// Look in the store for an access token
|
||||
Store.get(StoreKey.accessToken);
|
||||
|
||||
// Validate the access token with the server
|
||||
final res = await _apiService.authenticationApi.validateAccessToken();
|
||||
if (res == null || res.authStatus != true) {
|
||||
// Token may have changed during validation (user logged out + logged in
|
||||
// again); only act if it still applies to the current session.
|
||||
if (Store.tryGet(StoreKey.accessToken) != token) {
|
||||
return;
|
||||
}
|
||||
// If the access token is invalid, take user back to login
|
||||
_log.fine('User token is invalid. Redirecting to login');
|
||||
await router.replaceAll([const LoginRoute()]);
|
||||
await _authService.clearLocalData();
|
||||
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
|
||||
}
|
||||
} on StoreKeyNotFoundException catch (_) {
|
||||
// If there is no access token, take us to the login page
|
||||
_log.warning('No access token in the store.');
|
||||
unawaited(router.replaceAll([const LoginRoute()]));
|
||||
return;
|
||||
} on ApiException catch (e) {
|
||||
if (e.code != HttpStatus.unauthorized) {
|
||||
// On an unauthorized request, take us to the login page
|
||||
if (e.code == HttpStatus.unauthorized) {
|
||||
_log.warning("Unauthorized access token.");
|
||||
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
|
||||
return;
|
||||
}
|
||||
if (Store.tryGet(StoreKey.accessToken) != token) {
|
||||
return;
|
||||
}
|
||||
_log.warning("Unauthorized access token.");
|
||||
await router.replaceAll([const LoginRoute()]);
|
||||
await _authService.clearLocalData();
|
||||
} catch (e) {
|
||||
// Otherwise, this is not fatal, but we still log the warning
|
||||
_log.warning('Error validating access token from server: $e');
|
||||
} finally {
|
||||
_validateInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||
@@ -190,7 +189,6 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
RedirectRoute(path: '*', redirectTo: '/'),
|
||||
|
||||
@@ -1095,53 +1095,6 @@ class DriftSearchRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftSlideshowPage]
|
||||
class DriftSlideshowRoute extends PageRouteInfo<DriftSlideshowRouteArgs> {
|
||||
DriftSlideshowRoute({
|
||||
Key? key,
|
||||
required TimelineService timeline,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftSlideshowRoute.name,
|
||||
args: DriftSlideshowRouteArgs(key: key, timeline: timeline),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'DriftSlideshowRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftSlideshowRouteArgs>();
|
||||
return DriftSlideshowPage(key: args.key, timeline: args.timeline);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftSlideshowRouteArgs {
|
||||
const DriftSlideshowRouteArgs({this.key, required this.timeline});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final TimelineService timeline;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! DriftSlideshowRouteArgs) return false;
|
||||
return key == other.key && timeline == other.timeline;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode ^ timeline.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftTrashPage]
|
||||
class DriftTrashRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
@@ -24,7 +23,6 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||
|
||||
final actionServiceProvider = Provider<ActionService>(
|
||||
@@ -37,7 +35,6 @@ final actionServiceProvider = Provider<ActionService>(
|
||||
ref.watch(trashedLocalAssetRepository),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(downloadRepositoryProvider),
|
||||
ref.watch(tagServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -50,7 +47,6 @@ class ActionService {
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final DownloadRepository _downloadRepository;
|
||||
final TagService _tagService;
|
||||
|
||||
const ActionService(
|
||||
this._assetApiRepository,
|
||||
@@ -61,7 +57,6 @@ class ActionService {
|
||||
this._trashedLocalAssetRepository,
|
||||
this._assetMediaRepository,
|
||||
this._downloadRepository,
|
||||
this._tagService,
|
||||
);
|
||||
|
||||
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
|
||||
@@ -239,26 +234,6 @@ class ActionService {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int?> tagAssets(List<String> remoteIds, BuildContext context) async {
|
||||
final tagResults = await showTagPickerModal(context: context);
|
||||
if (tagResults == null) {
|
||||
// user cancelled
|
||||
return null;
|
||||
}
|
||||
|
||||
final selectedTagIds = Set<String>.from(tagResults.$1);
|
||||
final selectedNewTagValues = tagResults.$2;
|
||||
|
||||
if (selectedNewTagValues.isNotEmpty) {
|
||||
final upsertedTags = await _tagService.upsertTags(selectedNewTagValues.toList());
|
||||
selectedTagIds.addAll(upsertedTags.map((t) => t.id));
|
||||
}
|
||||
if (selectedTagIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList());
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||
final stack = await _assetApiRepository.stack(remoteIds);
|
||||
await _remoteAssetRepository.stack(userId, stack);
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -177,21 +177,30 @@ class ApiService {
|
||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
||||
urls.add(serverEndpoint);
|
||||
}
|
||||
final network = MetadataRepository.instance.systemConfig.network;
|
||||
final localEndpoint = network.localEndpoint;
|
||||
if (localEndpoint != null) {
|
||||
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
|
||||
if (localEndpoint != null && localEndpoint.isNotEmpty) {
|
||||
urls.add(localEndpoint);
|
||||
}
|
||||
for (final url in network.externalEndpointList) {
|
||||
if (url.isNotEmpty) {
|
||||
urls.add(url);
|
||||
final externalJson = Store.tryGet(StoreKey.externalEndpointList);
|
||||
if (externalJson != null) {
|
||||
final List<dynamic> list = jsonDecode(externalJson);
|
||||
for (final entry in list) {
|
||||
final url = AuxilaryEndpoint.fromJson(entry).url;
|
||||
if (url.isNotEmpty) {
|
||||
urls.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
static Map<String, String> getRequestHeaders() {
|
||||
return MetadataRepository.instance.systemConfig.network.customHeaders;
|
||||
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
|
||||
if (customHeadersStr.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
return (jsonDecode(customHeadersStr) as Map).cast<String, String>();
|
||||
}
|
||||
|
||||
ApiClient get apiClient => _apiClient;
|
||||
|
||||
@@ -2,14 +2,33 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
enum AppSettingsEnum<T> {
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
|
||||
uploadErrorNotificationGracePeriod<int>(
|
||||
StoreKey.uploadErrorNotificationGracePeriod,
|
||||
"uploadErrorNotificationGracePeriod",
|
||||
2,
|
||||
),
|
||||
thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000),
|
||||
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200),
|
||||
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
|
||||
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
|
||||
tapToNavigate<bool>(StoreKey.tapToNavigate, "tapToNavigate", false),
|
||||
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
||||
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
|
||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
|
||||
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
|
||||
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
||||
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
||||
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
|
||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
|
||||
|
||||
|
||||
@@ -123,6 +123,11 @@ class AuthService {
|
||||
_authRepository.clearLocalData(),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
Store.delete(StoreKey.assetETag),
|
||||
Store.delete(StoreKey.autoEndpointSwitching),
|
||||
Store.delete(StoreKey.preferredWifiName),
|
||||
Store.delete(StoreKey.localEndpoint),
|
||||
Store.delete(StoreKey.externalEndpointList),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -394,13 +394,9 @@ class BackgroundUploadService {
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final url = Uri.parse('$serverEndpoint/assets').toString();
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
|
||||
final fieldsMap = {
|
||||
'filename': originalFileName ?? filename,
|
||||
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
|
||||
'deviceAssetId': deviceAssetId ?? '',
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': isFavorite?.toString() ?? 'false',
|
||||
|
||||
@@ -5,8 +5,6 @@ import 'dart:io';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
@@ -321,12 +319,8 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
final fields = {
|
||||
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
|
||||
'deviceAssetId': asset.localId!,
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
@@ -432,9 +426,6 @@ class ForegroundUploadService {
|
||||
final filename = p.basename(file.path);
|
||||
|
||||
final fields = {
|
||||
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
|
||||
'deviceAssetId': deviceAssetId,
|
||||
'deviceId': Store.get(StoreKey.deviceId),
|
||||
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': 'false',
|
||||
|
||||
@@ -21,13 +21,11 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
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';
|
||||
@@ -74,7 +72,6 @@ enum ActionButtonType {
|
||||
similarPhotos,
|
||||
setProfilePicture,
|
||||
viewInTimeline,
|
||||
slideshow,
|
||||
download,
|
||||
upload,
|
||||
openInBrowser,
|
||||
@@ -84,7 +81,6 @@ enum ActionButtonType {
|
||||
moveToLockFolder,
|
||||
removeFromLockFolder,
|
||||
removeFromAlbum,
|
||||
restoreTrash,
|
||||
trash,
|
||||
deleteLocal,
|
||||
deletePermanent,
|
||||
@@ -116,17 +112,12 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.isTrashEnabled && //
|
||||
context.timelineOrigin != TimelineOrigin.trash,
|
||||
ActionButtonType.restoreTrash =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.timelineOrigin == TimelineOrigin.trash,
|
||||
context.isTrashEnabled,
|
||||
ActionButtonType.deletePermanent =>
|
||||
context.isOwner && //
|
||||
context.asset.hasRemote && //
|
||||
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
|
||||
context.asset.hasRemote && //
|
||||
!context.isTrashEnabled ||
|
||||
context.isInLockedView,
|
||||
ActionButtonType.delete =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
@@ -181,7 +172,6 @@ enum ActionButtonType {
|
||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||
context.isOwner,
|
||||
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||
ActionButtonType.slideshow => true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,7 +193,6 @@ enum ActionButtonType {
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unarchive => UnArchiveActionButton(
|
||||
source: context.source,
|
||||
@@ -212,11 +201,6 @@ enum ActionButtonType {
|
||||
),
|
||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.restoreTrash => RestoreActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
@@ -308,7 +292,6 @@ enum ActionButtonType {
|
||||
ActionButtonType.moveToLockFolder => 10,
|
||||
ActionButtonType.deleteLocal => 10,
|
||||
ActionButtonType.delete => 10,
|
||||
ActionButtonType.restoreTrash => 10,
|
||||
// 90: advancedInfo
|
||||
ActionButtonType.advancedInfo => 90,
|
||||
// 1: others
|
||||
@@ -326,8 +309,6 @@ class ActionButtonBuilder {
|
||||
ActionButtonType.delete,
|
||||
ActionButtonType.archive,
|
||||
ActionButtonType.unarchive,
|
||||
ActionButtonType.restoreTrash,
|
||||
ActionButtonType.deletePermanent,
|
||||
};
|
||||
|
||||
static List<Widget> build(ActionButtonContext context) {
|
||||
|
||||
+12
-124
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -13,8 +12,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
|
||||
const int targetVersion = 26;
|
||||
|
||||
@@ -39,35 +37,12 @@ Future<void> _migrateTo25() async {
|
||||
return;
|
||||
}
|
||||
|
||||
final urls = <String>[];
|
||||
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
|
||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
||||
urls.add(serverEndpoint);
|
||||
}
|
||||
final localEndpoint = Store.tryGet(StoreKey.legacyLocalEndpoint);
|
||||
if (localEndpoint != null && localEndpoint.isNotEmpty) {
|
||||
urls.add(localEndpoint);
|
||||
}
|
||||
final externalJson = Store.tryGet(StoreKey.legacyExternalEndpointList);
|
||||
if (externalJson != null) {
|
||||
final List<dynamic> list = jsonDecode(externalJson);
|
||||
for (final entry in list) {
|
||||
final url = AuxilaryEndpoint.fromJson(entry).url;
|
||||
if (url.isNotEmpty) {
|
||||
urls.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (urls.isEmpty) {
|
||||
final serverUrls = ApiService.getServerUrls();
|
||||
if (serverUrls.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final customHeadersStr = Store.get(StoreKey.legacyCustomHeaders, "");
|
||||
final headers = customHeadersStr.isEmpty
|
||||
? const <String, String>{}
|
||||
: (jsonDecode(customHeadersStr) as Map).cast<String, String>();
|
||||
|
||||
await NetworkRepository.setHeaders(headers, urls, token: accessToken);
|
||||
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
|
||||
}
|
||||
|
||||
Future<void> _migrateTo26(Drift drift) async {
|
||||
@@ -82,7 +57,14 @@ Future<void> _migrateTo26(Drift drift) async {
|
||||
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id);
|
||||
if (cleanupKeepAlbumIds != null) {
|
||||
final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList();
|
||||
migrator.stage(StoreKey.legacyCleanupKeepAlbumIds, MetadataKey.cleanupKeepAlbumIds, ids);
|
||||
await drift.metadataEntity.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(
|
||||
key: MetadataKey.cleanupKeepAlbumIds.key,
|
||||
value: MetadataKey.cleanupKeepAlbumIds.encode(ids),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
await migrator.deleteLegacyStoreRows([StoreKey.legacyCleanupKeepAlbumIds.id]);
|
||||
}
|
||||
await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, MetadataKey.cleanupKeepFavorites);
|
||||
await migrator.migrateEnumIndex(
|
||||
@@ -106,88 +88,9 @@ Future<void> _migrateTo26(Drift drift) async {
|
||||
GroupAssetsBy.values,
|
||||
);
|
||||
await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator);
|
||||
// Image
|
||||
await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, MetadataKey.imagePreferRemote);
|
||||
await migrator.migrateBool(StoreKey.legacyLoadOriginal, MetadataKey.imageLoadOriginal);
|
||||
// Viewer
|
||||
await migrator.migrateBool(StoreKey.legacyLoopVideo, MetadataKey.viewerLoopVideo);
|
||||
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo);
|
||||
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo);
|
||||
await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate);
|
||||
// Network
|
||||
await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, MetadataKey.networkAutoEndpointSwitching);
|
||||
await migrator.migrateString(StoreKey.legacyPreferredWifiName, MetadataKey.networkPreferredWifiName);
|
||||
await migrator.migrateString(StoreKey.legacyLocalEndpoint, MetadataKey.networkLocalEndpoint);
|
||||
await _migrateExternalEndpointList(migrator);
|
||||
await _migrateCustomHeaders(migrator);
|
||||
// Album
|
||||
await _migrateAlbumSortMode(migrator);
|
||||
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, MetadataKey.albumIsReverse);
|
||||
await migrator.migrateBool(StoreKey.legacyAlbumGridView, MetadataKey.albumIsGrid);
|
||||
await migrator.complete();
|
||||
}
|
||||
|
||||
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
|
||||
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final mode = AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == raw,
|
||||
orElse: () => MetadataKey.albumSortMode.defaultValue,
|
||||
);
|
||||
|
||||
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
|
||||
}
|
||||
|
||||
Future<void> _migrateExternalEndpointList(_StoreMigrator migrator) async {
|
||||
final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id);
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final urls = <String>[];
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
for (final entry in decoded) {
|
||||
final url = AuxilaryEndpoint.fromJson(entry).url;
|
||||
if (url.isNotEmpty) {
|
||||
urls.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
} on FormatException {
|
||||
// ignore invalid entries
|
||||
}
|
||||
|
||||
migrator.stage(StoreKey.legacyExternalEndpointList, MetadataKey.networkExternalEndpointList, urls);
|
||||
}
|
||||
|
||||
Future<void> _migrateCustomHeaders(_StoreMigrator migrator) async {
|
||||
final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id);
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final headers = <String, String>{};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map) {
|
||||
decoded.forEach((key, value) {
|
||||
if (key is String && value is String) {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
} on FormatException {
|
||||
// ignore invalid entries
|
||||
}
|
||||
|
||||
migrator.stage(StoreKey.legacyCustomHeaders, MetadataKey.networkCustomHeaders, headers);
|
||||
}
|
||||
|
||||
class _StoreMigrator {
|
||||
final Drift _db;
|
||||
final Map<MetadataKey<Object>, Object> _cache = {};
|
||||
@@ -242,21 +145,6 @@ class _StoreMigrator {
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
|
||||
Future<void> migrateString(StoreKey<String> legacyKey, MetadataKey<String> newKey) async {
|
||||
final value = await readLegacyStoreString(legacyKey.id);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_cache[newKey] = value;
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
|
||||
void stage<T extends Object>(StoreKey legacyKey, MetadataKey<T> newKey, T value) {
|
||||
_cache[newKey] = value;
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
|
||||
Future<void> complete() async {
|
||||
await _db.batch((batch) {
|
||||
for (final entry in _cache.entries) {
|
||||
|
||||
@@ -24,17 +24,6 @@ String? getServerUrl() {
|
||||
);
|
||||
}
|
||||
|
||||
String? buildSharedLinkUrl({required String? baseUrl, required String key, String? slug}) {
|
||||
if (baseUrl == null || baseUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
|
||||
final path = (slug != null && slug.isNotEmpty) ? 's/$slug' : 'share/$key';
|
||||
|
||||
return '$normalizedBaseUrl$path';
|
||||
}
|
||||
|
||||
/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode).
|
||||
///
|
||||
/// This is especially useful for internationalized domain names (IDNs),
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
|
||||
|
||||
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
|
||||
@@ -90,10 +89,6 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
|
||||
onPressed: () => context.maybePop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))),
|
||||
icon: Icon(Icons.slideshow_outlined, color: actionIconColor, shadows: actionIconShadows),
|
||||
),
|
||||
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
|
||||
IconButton(
|
||||
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
|
||||
|
||||
@@ -8,78 +8,12 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
|
||||
String _trimSlashes(String s) => s.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
|
||||
Future<(Set<String>, Set<String>)?> showTagPickerModal({required BuildContext context, Set<String>? initialSelection}) {
|
||||
return showDialog<(Set<String>, Set<String>)?>(
|
||||
context: context,
|
||||
builder: (context) => _TagPickerModal(initialSelection: initialSelection),
|
||||
);
|
||||
}
|
||||
|
||||
class _TagPickerModal extends HookConsumerWidget {
|
||||
final Set<String>? initialSelection;
|
||||
|
||||
const _TagPickerModal({this.initialSelection});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedTagIds = useState<Set<String>>(initialSelection ?? {});
|
||||
final newTagValues = useState<Set<String>>({});
|
||||
|
||||
void onSelectExistingTag(Iterable<Tag> tags) {
|
||||
selectedTagIds.value = tags.map((tag) => tag.id).toSet();
|
||||
}
|
||||
|
||||
void onSelectNewTag(Set<String> tags) {
|
||||
newTagValues.value = tags;
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 0),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
"cancel",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop((selectedTagIds.value, newTagValues.value)),
|
||||
child: Text(
|
||||
"action_common_update",
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.8,
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: TagPicker(
|
||||
onSelectExistingTag: onSelectExistingTag,
|
||||
filter: selectedTagIds.value,
|
||||
onSelectNewTag: onSelectNewTag,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TagPicker extends HookConsumerWidget {
|
||||
const TagPicker({super.key, required this.onSelectExistingTag, required this.filter, this.onSelectNewTag});
|
||||
const TagPicker({super.key, required this.onSelect, required this.filter});
|
||||
|
||||
final Function(Iterable<Tag>) onSelect;
|
||||
final Set<String> filter;
|
||||
|
||||
/// Callback when existing tags are selected/deselected.
|
||||
final Function(Iterable<Tag>) onSelectExistingTag;
|
||||
|
||||
/// If not null, shows a tile to create a new tag with user's filter input.
|
||||
final Function(Set<String>)? onSelectNewTag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formFocus = useFocusNode();
|
||||
@@ -87,7 +21,6 @@ class TagPicker extends HookConsumerWidget {
|
||||
final tags = ref.watch(tagProvider);
|
||||
final selectedTagIds = useState<Set<String>>(filter);
|
||||
final borderRadius = const BorderRadius.all(Radius.circular(10));
|
||||
final selectedNewTagValues = useState<Set<String>>({});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -108,53 +41,13 @@ class TagPicker extends HookConsumerWidget {
|
||||
Expanded(
|
||||
child: tags.widgetWhen(
|
||||
onData: (tags) {
|
||||
final trimmedQuery = _trimSlashes(searchQuery.value);
|
||||
final queryResult = tags
|
||||
.where((t) => t.value.toLowerCase().contains(trimmedQuery.toLowerCase()))
|
||||
.where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.toList();
|
||||
final showCreateTile =
|
||||
(onSelectNewTag != null) &&
|
||||
trimmedQuery.isNotEmpty &&
|
||||
!tags.any((t) => t.value.toLowerCase() == trimmedQuery.toLowerCase());
|
||||
final isCreateSelected = selectedNewTagValues.value.contains(trimmedQuery);
|
||||
return ListView.builder(
|
||||
itemCount: queryResult.length + (showCreateTile ? 1 : 0),
|
||||
itemCount: queryResult.length,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) {
|
||||
if (showCreateTile && index == queryResult.length) {
|
||||
// Create new tag tile
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
trimmedQuery,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.add,
|
||||
color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
|
||||
),
|
||||
onTap: () {
|
||||
final newSelectedNewTagValues = {...selectedNewTagValues.value};
|
||||
if (isCreateSelected) {
|
||||
newSelectedNewTagValues.remove(trimmedQuery);
|
||||
} else {
|
||||
newSelectedNewTagValues.add(trimmedQuery);
|
||||
}
|
||||
selectedNewTagValues.value = newSelectedNewTagValues;
|
||||
onSelectNewTag!.call(newSelectedNewTagValues);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final tag = queryResult[index];
|
||||
final isSelected = selectedTagIds.value.any((id) => id == tag.id);
|
||||
|
||||
@@ -180,7 +73,7 @@ class TagPicker extends HookConsumerWidget {
|
||||
newSelected.add(tag.id);
|
||||
}
|
||||
selectedTagIds.value = newSelected;
|
||||
onSelectExistingTag(tags.where((t) => newSelected.contains(t.id)));
|
||||
onSelect(tags.where((t) => newSelected.contains(t.id)));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -397,16 +397,19 @@ class LoginForm extends HookConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
ImmichForm(
|
||||
onSubmit: getServerAuthSettings,
|
||||
submitText: 'next'.t(context: context),
|
||||
submitIcon: Icons.arrow_forward_rounded,
|
||||
builder: (_, form) => ImmichURLInput(
|
||||
onSubmit: getServerAuthSettings,
|
||||
child: ImmichTextInput(
|
||||
controller: serverEndpointController,
|
||||
label: 'login_form_endpoint_url'.t(context: context),
|
||||
hintText: 'login_form_endpoint_hint'.t(context: context),
|
||||
validator: _validateUrl,
|
||||
keyboardAction: .next,
|
||||
onSubmit: (_) => form.submit(),
|
||||
keyboardAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.url,
|
||||
autofillHints: const [AutofillHints.url],
|
||||
autoCorrect: false,
|
||||
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
|
||||
),
|
||||
),
|
||||
ImmichTextButton(
|
||||
@@ -434,10 +437,10 @@ class LoginForm extends HookConsumerWidget {
|
||||
),
|
||||
if (isPasswordLoginEnable.value)
|
||||
ImmichForm(
|
||||
onSubmit: login,
|
||||
submitText: 'login'.t(context: context),
|
||||
submitIcon: Icons.login_rounded,
|
||||
builder: (context, form) => Column(
|
||||
onSubmit: login,
|
||||
child: Column(
|
||||
spacing: ImmichSpacing.md,
|
||||
children: [
|
||||
ImmichTextInput(
|
||||
@@ -448,7 +451,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
keyboardAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
onSubmit: (_) => passwordFocusNode.requestFocus(),
|
||||
onSubmit: (_, _) => passwordFocusNode.requestFocus(),
|
||||
),
|
||||
ImmichPasswordInput(
|
||||
controller: passwordController,
|
||||
@@ -456,17 +459,17 @@ class LoginForm extends HookConsumerWidget {
|
||||
label: 'password'.t(context: context),
|
||||
hintText: 'login_form_password_hint'.t(context: context),
|
||||
keyboardAction: TextInputAction.go,
|
||||
onSubmit: (_) => form.submit(),
|
||||
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isOauthEnable.value)
|
||||
ImmichForm(
|
||||
onSubmit: oAuthLogin,
|
||||
submitText: oAuthButtonLabel.value,
|
||||
submitIcon: Icons.pin_outlined,
|
||||
builder: (context, _) => isPasswordLoginEnable.value
|
||||
onSubmit: oAuthLogin,
|
||||
child: isPasswordLoginEnable.value
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0),
|
||||
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5),
|
||||
|
||||
@@ -32,11 +32,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
|
||||
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
|
||||
useValueChanged(
|
||||
preferRemote.value,
|
||||
(_, __) => ref.read(metadataProvider).write(.imagePreferRemote, preferRemote.value),
|
||||
);
|
||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
|
||||
|
||||
final logLevel = Level.LEVELS[levelId.value].name;
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/slideshow_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
|
||||
class AssetViewerSettings extends StatelessWidget {
|
||||
@@ -14,7 +13,6 @@ class AssetViewerSettings extends StatelessWidget {
|
||||
const ImageViewerQualitySetting(),
|
||||
const ImageViewerTapToNavigateSetting(),
|
||||
const VideoViewerSettings(),
|
||||
const SlideshowSettings(),
|
||||
];
|
||||
|
||||
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
|
||||
@@ -12,10 +12,7 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal);
|
||||
useValueChanged<bool, void>(isOriginal.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.imageLoadOriginal, isOriginal.value);
|
||||
});
|
||||
final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
+5
-6
@@ -1,20 +1,18 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
|
||||
class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
|
||||
const ImageViewerTapToNavigateSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate);
|
||||
useValueChanged<bool, void>(tapToNavigate.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.viewerTapToNavigate, tapToNavigate.value);
|
||||
});
|
||||
final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -24,6 +22,7 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
|
||||
valueNotifier: tapToNavigate,
|
||||
title: "setting_image_navigation_enable_title".tr(),
|
||||
subtitle: "setting_image_navigation_enable_subtitle".tr(),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
|
||||
class SlideshowSettings extends HookConsumerWidget {
|
||||
const SlideshowSettings({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final slideshow = ref.read(appConfigProvider).slideshow;
|
||||
final useTransition = useState(slideshow.transition);
|
||||
final useRepeat = useState(slideshow.repeat);
|
||||
final useDuration = useState(slideshow.duration);
|
||||
final useLook = useState(slideshow.look);
|
||||
final useDirection = useState(slideshow.direction);
|
||||
|
||||
useValueChanged<bool, void>(useTransition.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowTransition, useTransition.value);
|
||||
});
|
||||
useValueChanged<bool, void>(useRepeat.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowRepeat, useRepeat.value);
|
||||
});
|
||||
useValueChanged<int, void>(useDuration.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowDuration, useDuration.value);
|
||||
});
|
||||
useValueChanged<SlideshowLook, void>(useLook.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowLook, useLook.value);
|
||||
});
|
||||
useValueChanged<SlideshowDirection, void>(useDirection.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowDirection, useDirection.value);
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingGroupTitle(
|
||||
title: 'slideshow'.t(context: context),
|
||||
icon: Icons.slideshow_outlined,
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useTransition,
|
||||
title: "show_slideshow_transition".t(context: context),
|
||||
enabled: useDirection.value != SlideshowDirection.shuffle,
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useRepeat,
|
||||
title: "slideshow_repeat".t(context: context),
|
||||
subtitle: "slideshow_repeat_description".t(context: context),
|
||||
),
|
||||
SettingsSliderListTile(
|
||||
valueNotifier: useDuration,
|
||||
text: "duration".t(context: context),
|
||||
minValue: 5,
|
||||
noDivisons: 5,
|
||||
maxValue: 30,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: SettingsSubTitle(title: 'look'.t(context: context)),
|
||||
),
|
||||
SettingsRadioListTile(
|
||||
groups: [
|
||||
SettingsRadioGroup(
|
||||
title: 'contain'.t(context: context),
|
||||
value: SlideshowLook.contain,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'cover'.t(context: context),
|
||||
value: SlideshowLook.cover,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'blurred_background'.t(context: context),
|
||||
value: SlideshowLook.blurredBackground,
|
||||
),
|
||||
],
|
||||
groupBy: useLook.value,
|
||||
onRadioChanged: (value) {
|
||||
if (value != null) {
|
||||
useLook.value = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: SettingsSubTitle(title: 'direction'.t(context: context)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
child: SettingsRadioListTile(
|
||||
groups: [
|
||||
SettingsRadioGroup(
|
||||
title: 'forward'.t(context: context),
|
||||
value: SlideshowDirection.forward,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'backward'.t(context: context),
|
||||
value: SlideshowDirection.backward,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'shuffle'.t(context: context),
|
||||
value: SlideshowDirection.shuffle,
|
||||
),
|
||||
],
|
||||
groupBy: useDirection.value,
|
||||
onRadioChanged: (value) {
|
||||
if (value != null) {
|
||||
useDirection.value = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
|
||||
class VideoViewerSettings extends HookConsumerWidget {
|
||||
const VideoViewerSettings({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final viewer = ref.read(appConfigProvider).viewer;
|
||||
final useAutoPlayVideo = useState(viewer.autoPlayVideo);
|
||||
final useLoopVideo = useState(viewer.loopVideo);
|
||||
final useOriginalVideo = useState(viewer.loadOriginalVideo);
|
||||
|
||||
useValueChanged<bool, void>(useAutoPlayVideo.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value);
|
||||
});
|
||||
useValueChanged<bool, void>(useLoopVideo.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.viewerLoopVideo, useLoopVideo.value);
|
||||
});
|
||||
useValueChanged<bool, void>(useOriginalVideo.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value);
|
||||
});
|
||||
final useLoopVideo = useAppSettingsState(AppSettingsEnum.loopVideo);
|
||||
final useOriginalVideo = useAppSettingsState(AppSettingsEnum.loadOriginalVideo);
|
||||
final useAutoPlayVideo = useAppSettingsState(AppSettingsEnum.autoPlayVideo);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -37,16 +27,19 @@ class VideoViewerSettings extends HookConsumerWidget {
|
||||
valueNotifier: useAutoPlayVideo,
|
||||
title: "setting_video_viewer_auto_play_title".t(context: context),
|
||||
subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useLoopVideo,
|
||||
title: "setting_video_viewer_looping_title".t(context: context),
|
||||
subtitle: "loop_videos_description".t(context: context),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useOriginalVideo,
|
||||
title: "setting_video_viewer_original_video_title".t(context: context),
|
||||
subtitle: "setting_video_viewer_original_video_subtitle".t(context: context),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user