Compare commits

...

22 Commits

Author SHA1 Message Date
shenlong-tanwen 3c9becd9ea replace drift_flutter with drift_sqlite_async 2026-05-15 16:02:52 +05:30
Ben Beckford 21d6755f39 fix(web): recently added ux (#28435) 2026-05-14 22:22:23 -05:00
Robert Deaton e91c017dd0 fix(server): dedupe database backup jobs (#28341)
* fix(server): dedupe database backup jobs via jobId

#27268 shows backup jobs piling up in the queue across upgrades; one pending
backup is always enough.

* fix(tests): Avoid stale backup files from previous test runs being erroneously returned from createBackup

* fix(jobs): Use bullmq's deduplication over jobId to avoid failed jobs from blocking future executions.

---------

Co-authored-by: Robert Deaton <immich@rdeaton.space>
2026-05-14 20:59:15 -04:00
Alex 43687cd8b4 fix: kebab menu icon colors and actions (#28433) 2026-05-14 22:23:50 +00:00
shenlong 06729ee5a5 chore: cleanup unused store keys (#28415)
cleanup unused store keys

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-14 16:21:06 -05:00
Nojus Gudinavičius b0c9743d9a feat(server): allow subpaths for machine learning URL (#28427)
This allows to use a machine learning server URL under a subpath,
such as "http://example.com/ml-server/".
2026-05-14 12:46:31 +00:00
Marius 37cc028868 fix(mobile): use correct delete action (#26575)
fix(mobile): use correct delete for trashed assets

When viewing a trashed asset, the viewer bottom bar now shows the permanent delete button instead of the trash button, which had no effect on already-trashed assets.
2026-05-14 11:57:19 +00:00
Inês Costa 84a2b7a3c8 fix(mobile): add restore option to trashed assets (#27442) 2026-05-14 07:19:00 +00:00
racehd 89b3433346 feat(docs): add fixed subnet guide for Synology to prevent firewall issues (#26554)
* - Add Set Fixed Subnet section
- Add newline after details summary to properly render summary with mdx

* pnpm run format --write

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-05-13 23:54:13 +00:00
shenlong 3ff0d47ee3 chore: do not cache dart_tool (#28409)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-13 19:46:24 -04:00
shenlong aeaf846482 chore: cleanup unused store keys (#28415)
cleanup unused store keys

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-13 18:03:57 -05:00
Santo Shakil b031548791 fix(mobile): don't block app open on slow validateAccessToken (#28405)
* fix(mobile): don't block app open on slow validateAccessToken

AuthGuard.onNavigation was async so auto_route awaited the body through validateAccessToken's OS timeout. now it's sync and the validate runs in bg. kicks to login on 401.

* fix(mobile): handle re-login race in AuthGuard validate

if user logs out + logs back in during a slow validate, the old 401 was logging them out again. now we check the token hasn't changed before redirecting, and dedupe in-flight calls.

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-05-13 11:52:43 -05:00
Jason Rasmussen fcea617313 fix: ignore icc profile make and model (#28412) 2026-05-13 12:07:35 -04:00
Mees Frensel 024f20ea26 chore(web): use DatePicker component from UI lib (#28406) 2026-05-13 09:37:07 -05:00
shenlong 0a4ed6fd71 refactor: migrate viewer config to metadata table (#28396)
* refactor: app metadata

* refactor to per row store

* cleanup

* more test

* review changes

* more refactor

* refactor

* migrate primary color

* migrate dynamic theme

* migrate colorfulInterface

* cleanup providers

* migrate cleanup

* migrate mapconfig

* remove unused keys

* migrate timeline config

* migrate image config

* migrate viewer config

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-13 09:36:19 -05:00
Alex b6e2ce1f35 fix(mobile): revert drop deprecated deviceAssetId / deviceId from upload fields (#28384) (#28400)
* Revert "chore(mobile): drop deprecated deviceAssetId / deviceId from upload fields (#28384)"

This reverts commit 571e6a8560.

* chore(mobile): add note on kept deprecated upload fields

---------

Co-authored-by: Santo Shakil <shakil.mezbah@gmail.com>
2026-05-13 09:36:16 -05:00
bo0tzz e323e778cd fix: update server-commands subcommand list (#28402) 2026-05-13 09:27:25 -04:00
renovate[bot] 6a87797649 chore(deps): update terraform cloudflare to v4.52.7 (#28370)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 23:50:23 -04:00
renovate[bot] f4a4649bbc chore(deps): update dependency canvas to v3 (#28376)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 23:49:22 -04:00
Alex 6ca54ee722 feat: display more info in asset viewer (#24630)
* feat(mobile): more info for asset viewer

* feat(mobile): more info for asset viewer
2026-05-13 02:07:23 +00:00
shenlong 8e3035f783 chore: run mobile tests in parallel (#28393)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-12 17:17:07 -05:00
shenlong 79801595db refactor: move image config to metadata table (#28228)
* migrate image config

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-13 03:20:35 +05:30
64 changed files with 967 additions and 546 deletions
-2
View File
@@ -116,7 +116,6 @@ jobs:
~/.gradle/wrapper ~/.gradle/wrapper
~/.android/sdk ~/.android/sdk
mobile/android/.gradle mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Android SDK - name: Setup Android SDK
@@ -189,7 +188,6 @@ jobs:
~/.gradle/wrapper ~/.gradle/wrapper
~/.android/sdk ~/.android/sdk
mobile/android/.gradle mobile/android/.gradle
mobile/.dart_tool
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }} key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
build-sign-ios: build-sign-ios:
+1 -1
View File
@@ -565,7 +565,7 @@ jobs:
run: mise //mobile:codegen:translation run: mise //mobile:codegen:translation
- name: Run tests - name: Run tests
run: mise //mobile:test -j 1 run: mise //mobile:test
ml-unit-tests: ml-unit-tests:
name: Unit Test ML name: Unit Test ML
+30 -30
View File
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.5" version = "4.52.7"
constraints = "4.52.5" constraints = "4.52.7"
hashes = [ hashes = [
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=", "h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=", "h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=", "h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=", "h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=", "h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=", "h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=", "h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=", "h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=", "h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=", "h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=", "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=", "h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=", "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=", "h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b", "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a", "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7", "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238", "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b", "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54", "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852", "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0", "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5", "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036", "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803", "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
] ]
} }
@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.5" version = "4.52.7"
} }
} }
} }
+30 -30
View File
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.5" version = "4.52.7"
constraints = "4.52.5" constraints = "4.52.7"
hashes = [ hashes = [
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=", "h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=", "h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=", "h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=", "h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=", "h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=", "h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=", "h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=", "h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=", "h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=", "h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=", "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=", "h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=", "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=", "h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b", "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a", "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7", "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238", "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b", "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54", "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852", "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0", "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5", "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036", "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803", "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.5" version = "4.52.7"
} }
} }
} }
@@ -13,8 +13,11 @@ The `immich-server` docker image comes preinstalled with an administrative CLI (
| `enable-oauth-login` | Enable OAuth login | | `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login | | `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users | | `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 | | `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location | | `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 ## How to run a command
@@ -102,6 +105,22 @@ 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 Print Immich Version
``` ```
@@ -126,3 +145,12 @@ immich-admin change-media-location
Database file paths updated successfully! 🎉 Database file paths updated successfully! 🎉
... ...
``` ```
Schema Check
```
immich-admin schema-check
Migrations are up to date
No schema drift detected
```
+66 -2
View File
@@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list
## Step 4 - Configure Firewall Settings ## 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. 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.
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**" Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
@@ -74,6 +74,7 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
<details> <details>
<summary>Updating Immich using Container Manager</summary> <summary>Updating Immich using Container Manager</summary>
Check the post installation and upgrade instructions at the links above before proceeding with this section. Check the post installation and upgrade instructions at the links above before proceeding with this section.
## Step 1. Backup ## Step 1. Backup
@@ -110,7 +111,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
## Step 5. Update firewall rule ## Step 5. Update firewall rule
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. 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.
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address. Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
![Container IP](../../static/img/synology-container-ip.png) ![Container IP](../../static/img/synology-container-ip.png)
@@ -123,4 +124,67 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi
![Edit IP](../../static/img/synology-fw-ipedit.png) ![Edit IP](../../static/img/synology-fw-ipedit.png)
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.
![Container IP](../../static/img/synology-container-ip.png)
## 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> </details>
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils'; import { app, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/admin/database-backups', () => { describe('/admin/database-backups', () => {
let cookie: string | undefined; let cookie: string | undefined;
@@ -13,6 +13,9 @@ describe('/admin/database-backups', () => {
admin = await utils.adminSetup({ admin = await utils.adminSetup({
onboarding: false, onboarding: false,
}); });
});
beforeEach(async () => {
await utils.resetBackups(admin.accessToken); await utils.resetBackups(admin.accessToken);
}); });
+2
View File
@@ -568,6 +568,8 @@ export const utils = {
name: ManualJobName.BackupDatabase, name: ManualJobName.BackupDatabase,
}); });
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
return utils.poll( return utils.poll(
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`), () => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
({ status, body }) => status === 200 && body.backups.length === 1, ({ status, body }) => status === 200 && body.backups.length === 1,
+3 -4
View File
@@ -885,15 +885,13 @@
"cutoff_date_description": "Keep photos from the last…", "cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}", "cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}", "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": "Dark",
"dark_theme": "Switch to dark theme", "dark_theme": "Switch to dark theme",
"date": "Date", "date": "Date",
"date_after": "Date after", "date_after": "Date after",
"date_and_time": "Date and Time", "date_and_time": "Date and Time",
"date_before": "Date before", "date_before": "Date before",
"date_format": "E, LLL d, y • h:mm a", "date_of_birth": "Date of birth",
"date_of_birth_saved": "Date of birth saved successfully", "date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range", "date_range": "Date range",
"day": "Day", "day": "Day",
@@ -1403,6 +1401,7 @@
"link_to_oauth": "Link to OAuth", "link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account", "linked_oauth_account": "Linked OAuth account",
"list": "List", "list": "List",
"live": "Live",
"loading": "Loading", "loading": "Loading",
"loading_search_results_failed": "Loading search results failed", "loading_search_results_failed": "Loading search results failed",
"local": "Local", "local": "Local",
@@ -1582,8 +1581,8 @@
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options", "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model", "model": "Model",
"month": "Month", "month": "Month",
"monthly_title_text_date_format": "MMMM y",
"more": "More", "more": "More",
"motion": "Motion",
"move": "Move", "move": "Move",
"move_down": "Move down", "move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder", "move_off_locked_folder": "Move out of locked folder",
@@ -11,6 +11,7 @@ class RemoteAsset extends BaseAsset {
final String ownerId; final String ownerId;
final String? stackId; final String? stackId;
final DateTime? uploadedAt; final DateTime? uploadedAt;
final DateTime? deletedAt;
const RemoteAsset({ const RemoteAsset({
required this.id, required this.id,
@@ -31,6 +32,7 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
required super.isEdited, required super.isEdited,
this.deletedAt,
}) : localAssetId = localId; }) : localAssetId = localId;
@override @override
@@ -48,6 +50,8 @@ class RemoteAsset extends BaseAsset {
@override @override
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage; bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
bool get isTrashed => deletedAt != null;
@override @override
String toString() { String toString() {
return '''Asset { return '''Asset {
@@ -86,7 +90,8 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash && thumbHash == other.thumbHash &&
visibility == other.visibility && visibility == other.visibility &&
stackId == other.stackId && stackId == other.stackId &&
uploadedAt == other.uploadedAt; uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
} }
@override @override
@@ -98,7 +103,8 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^ thumbHash.hashCode ^
visibility.hashCode ^ visibility.hashCode ^
stackId.hashCode ^ stackId.hashCode ^
uploadedAt.hashCode; uploadedAt.hashCode ^
deletedAt.hashCode;
RemoteAsset copyWith({ RemoteAsset copyWith({
String? id, String? id,
@@ -119,6 +125,7 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId, String? livePhotoVideoId,
String? stackId, String? stackId,
bool? isEdited, bool? isEdited,
DateTime? deletedAt,
}) { }) {
return RemoteAsset( return RemoteAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -139,6 +146,7 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId, stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited, isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
); );
} }
} }
@@ -156,6 +164,7 @@ class RemoteAssetExif extends RemoteAsset {
required super.createdAt, required super.createdAt,
required super.updatedAt, required super.updatedAt,
super.uploadedAt, super.uploadedAt,
super.deletedAt,
super.width, super.width,
super.height, super.height,
super.durationMs, super.durationMs,
@@ -193,6 +202,7 @@ class RemoteAssetExif extends RemoteAsset {
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? uploadedAt, DateTime? uploadedAt,
DateTime? deletedAt,
int? width, int? width,
int? height, int? height,
int? durationMs, int? durationMs,
@@ -214,6 +224,7 @@ class RemoteAssetExif extends RemoteAsset {
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt, uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width, width: width ?? this.width,
height: height ?? this.height, height: height ?? this.height,
durationMs: durationMs ?? this.durationMs, durationMs: durationMs ?? this.durationMs,
@@ -1,26 +1,41 @@
import 'package:immich_mobile/domain/models/config/cleanup_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/map_config.dart';
import 'package:immich_mobile/domain/models/config/theme_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/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
class AppConfig { class AppConfig {
final ThemeConfig theme; final ThemeConfig theme;
final CleanupConfig cleanup; final CleanupConfig cleanup;
final MapConfig map; final MapConfig map;
final TimelineConfig timeline; final TimelineConfig timeline;
final ImageConfig image;
final ViewerConfig viewer;
const AppConfig({ const AppConfig({
this.theme = const .new(), this.theme = const .new(),
this.cleanup = const .new(), this.cleanup = const .new(),
this.map = const .new(), this.map = const .new(),
this.timeline = const .new(), this.timeline = const .new(),
this.image = const .new(),
this.viewer = const .new(),
}); });
AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, TimelineConfig? timeline}) => .new( AppConfig copyWith({
ThemeConfig? theme,
CleanupConfig? cleanup,
MapConfig? map,
TimelineConfig? timeline,
ImageConfig? image,
ViewerConfig? viewer,
}) => .new(
theme: theme ?? this.theme, theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup, cleanup: cleanup ?? this.cleanup,
map: map ?? this.map, map: map ?? this.map,
timeline: timeline ?? this.timeline, timeline: timeline ?? this.timeline,
image: image ?? this.image,
viewer: viewer ?? this.viewer,
); );
@override @override
@@ -30,11 +45,14 @@ class AppConfig {
other.theme == theme && other.theme == theme &&
other.cleanup == cleanup && other.cleanup == cleanup &&
other.map == map && other.map == map &&
other.timeline == timeline); other.timeline == timeline &&
other.image == image &&
other.viewer == viewer);
@override @override
int get hashCode => Object.hash(theme, cleanup, map, timeline); int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer);
@override @override
String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline)'; String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)';
} }
@@ -0,0 +1,20 @@
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)';
}
@@ -0,0 +1,37 @@
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)';
}
@@ -24,6 +24,16 @@ enum MetadataKey<T extends Object> {
themeDynamic<bool>(.appConfig, 'theme.dynamic', false), themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true), 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),
// Timeline // Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4), timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>( timelineGroupAssetsBy<GroupAssetsBy>(
@@ -1,10 +1,6 @@
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
enum Setting<T> { 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), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
enableBackup<bool>(StoreKey.enableBackup, false); enableBackup<bool>(StoreKey.enableBackup, false);
+6 -21
View File
@@ -4,29 +4,15 @@ import 'package:immich_mobile/domain/models/user.model.dart';
/// Defines the data type for each value /// Defines the data type for each value
enum StoreKey<T> { enum StoreKey<T> {
version<int>._(0), version<int>._(0),
assetETag<String>._(1),
currentUser<UserDto>._(2), currentUser<UserDto>._(2),
deviceIdHash<int>._(3),
deviceId<String>._(4), deviceId<String>._(4),
backupFailedSince<DateTime>._(5),
backupRequireWifi<bool>._(6),
backupRequireCharging<bool>._(7), backupRequireCharging<bool>._(7),
backupTriggerDelay<int>._(8), backupTriggerDelay<int>._(8),
serverUrl<String>._(10), serverUrl<String>._(10),
accessToken<String>._(11), accessToken<String>._(11),
serverEndpoint<String>._(12), 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), selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114), advancedTroubleshooting<bool>._(114),
preferRemoteImage<bool>._(116),
selfSignedCert<bool>._(120),
selectedAlbumSortReverse<bool>._(123), selectedAlbumSortReverse<bool>._(123),
enableHapticFeedback<bool>._(126), enableHapticFeedback<bool>._(126),
customHeaders<String>._(127), customHeaders<String>._(127),
@@ -42,13 +28,6 @@ enum StoreKey<T> {
// Read-only Mode settings // Read-only Mode settings
readonlyModeEnabled<bool>._(138), readonlyModeEnabled<bool>._(138),
albumGridView<bool>._(140), albumGridView<bool>._(140),
loadOriginal<bool>._(101),
// Image viewer navigation settings
loopVideo<bool>._(117),
loadOriginalVideo<bool>._(136),
autoPlayVideo<bool>._(139),
tapToNavigate<bool>._(141),
// Experimental stuff // Experimental stuff
enableBackup<bool>._(1003), enableBackup<bool>._(1003),
@@ -57,6 +36,12 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013), syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store // Legacy keys that have been migrated to the new metadata store
legacyLoopVideo<bool>._(117),
legacyLoadOriginalVideo<bool>._(136),
legacyAutoPlayVideo<bool>._(139),
legacyTapToNavigate<bool>._(141),
legacyPreferRemoteImage<bool>._(116),
legacyLoadOriginal<bool>._(101),
legacyPrimaryColor<String>._(128), legacyPrimaryColor<String>._(128),
legacyDynamicTheme<bool>._(129), legacyDynamicTheme<bool>._(129),
legacyColorfulInterface<bool>._(130), legacyColorfulInterface<bool>._(130),
@@ -74,5 +74,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
localId: localId, localId: localId,
stackId: stackId, stackId: stackId,
isEdited: isEdited, isEdited: isEdited,
deletedAt: deletedAt,
); );
} }
@@ -1,7 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
@@ -31,6 +32,10 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase( @DriftDatabase(
tables: [ tables: [
@@ -60,8 +65,9 @@ import 'package:logging/logging.dart';
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
) )
class Drift extends $Drift { class Drift extends $Drift {
Drift([QueryExecutor? executor]) Drift(super.executor);
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
Future<void> reset() async { Future<void> reset() async {
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94 // https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
@@ -305,3 +311,18 @@ class DriftDatabaseRepository {
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback); Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
} }
Future<SqliteConnection> openSqliteConnection({required String name}) async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, '$name.sqlite'));
return SqliteDatabase(path: file.path);
}
Future<void> configureSqliteCache() async {
// Make sqlite3 pick a more suitable location for temporary files - the
// one from the system may be inaccessible due to sand-boxing.
final cacheBase = (await getTemporaryDirectory()).path;
// We can't access /tmp on Android, which sqlite3 would try by default.
// Explicitly tell it about the correct temporary directory.
sqlite3.tempDirectory = cacheBase;
}
@@ -1,14 +1,14 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(tables: [LogMessageEntity]) @DriftDatabase(tables: [LogMessageEntity])
class DriftLogger extends $DriftLogger { class DriftLogger extends $DriftLogger {
DriftLogger([QueryExecutor? executor]) DriftLogger.fromExecutor(super.executor);
: super(
executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)), DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
);
@override @override
int get schemaVersion => 1; int get schemaVersion => 1;
@@ -19,7 +19,8 @@ class DriftLogger extends $DriftLogger {
await customStatement('PRAGMA foreign_keys = ON'); await customStatement('PRAGMA foreign_keys = ON');
await customStatement('PRAGMA synchronous = NORMAL'); await customStatement('PRAGMA synchronous = NORMAL');
await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA journal_mode = WAL');
await customStatement('PRAGMA busy_timeout = 500'); await customStatement('PRAGMA busy_timeout = 30000'); // 30s
await customStatement('PRAGMA cache_size = -32000'); // 32MB
await customStatement('PRAGMA temp_store = MEMORY'); await customStatement('PRAGMA temp_store = MEMORY');
}, },
); );
@@ -132,6 +132,13 @@ extension<T extends Object> on MetadataDomain<T> {
groupAssetsBy: repo._read(.timelineGroupAssetsBy), groupAssetsBy: repo._read(.timelineGroupAssetsBy),
storageIndicator: repo._read(.timelineStorageIndicator), 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),
),
); );
case .systemConfig: case .systemConfig:
repo._systemConfig = .new(logLevel: repo._read(.logLevel)); repo._systemConfig = .new(logLevel: repo._read(.logLevel));
@@ -35,10 +35,11 @@ class BaseActionButton extends ConsumerWidget {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context); final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0; final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color; final textColor = context.themeData.textTheme.labelLarge?.color;
if (iconOnly) { if (iconOnly) {
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return IconButton( return IconButton(
onPressed: onPressed, onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor), icon: Icon(iconData, size: iconSize, color: iconColor),
@@ -46,17 +47,18 @@ class BaseActionButton extends ConsumerWidget {
} }
if (menuItem) { if (menuItem) {
final theme = context.themeData; final iconColor = this.iconColor;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton( return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor), leadingIcon: Icon(iconData, color: iconColor),
onPressed: onPressed, onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)), child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
); );
} }
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth), constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton( child: MaterialButton(
@@ -18,8 +18,15 @@ class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly; final bool iconOnly;
final bool menuItem; final bool menuItem;
final bool useShortLabel;
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const DeletePermanentActionButton({
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
this.useShortLabel = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -64,7 +71,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
maxWidth: 110.0, maxWidth: 110.0,
iconData: Icons.delete_forever, iconData: Icons.delete_forever,
label: "delete_permanently".t(context: context), label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
iconOnly: iconOnly, iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
@@ -0,0 +1,55 @@
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,
);
}
}
@@ -17,11 +17,10 @@ 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/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.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/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/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.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/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/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -231,7 +230,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
return; return;
} }
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate); final tapToNavigate = ref.read(metadataProvider).appConfig.viewer.tapToNavigate;
if (!tapToNavigate) { if (!tapToNavigate) {
_viewer.toggleControls(); _viewer.toggleControls();
return; return;
@@ -2,15 +2,19 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/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_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_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/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/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -33,23 +37,31 @@ class ViewerBottomBar extends ConsumerWidget {
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider); final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final originalTheme = context.themeData; final originalTheme = context.themeData;
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[ if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (!isInTrash) ...[
// edit sync was added in 2.6.0 if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) // edit sync was added in 2.6.0
const EditImageActionButton(), if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[ if (isOwner) ...[
asset.isLocalOnly if (asset.isLocalOnly)
? const DeleteLocalActionButton(source: ActionSource.viewer) const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
], ],
], ],
]; ];
@@ -0,0 +1,78 @@
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,21 +3,17 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.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/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.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/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.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/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/services/api.service.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:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
@@ -132,7 +128,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final remoteId = (videoAsset as RemoteAsset).id; final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo); final isOriginalVideo = ref.read(metadataProvider).appConfig.viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
@@ -165,7 +161,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return; return;
} }
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); final autoPlayVideo = ref.read(metadataProvider).appConfig.viewer.autoPlayVideo;
if (autoPlayVideo || widget.asset.isMotionPhoto) { if (autoPlayVideo || widget.asset.isMotionPhoto) {
await _notifier.play(); await _notifier.play();
} }
@@ -216,7 +212,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
} }
await _notifier.load(source); await _notifier.load(source);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo); final loopVideo = ref.read(metadataProvider).appConfig.viewer.loopVideo;
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1); await _notifier.setVolume(1);
} }
@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
@@ -10,11 +11,13 @@ 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/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.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/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/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key}); const ViewerTopAppBar({super.key});
@@ -95,16 +98,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
), ),
SafeArea( SafeArea(
bottom: false, bottom: false,
child: SizedBox.square( child: SizedBox(
height: preferredSize.height,
child: Theme( child: Theme(
data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)), data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)),
child: Row( child: NavigationToolbar(
children: [ centerMiddle: true,
const _AppBarBackButton(), leading: const _AppBarBackButton(),
const Spacer(), middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
if (!showingDetails && !isReadonlyModeEnabled) trailing: !showingDetails && !isReadonlyModeEnabled
if (isInLockedView) ...lockedViewActions else ...actions, ? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
], : null,
), ),
), ),
), ),
@@ -139,3 +143,32 @@ 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)),
],
);
}
}
@@ -3,9 +3,8 @@ import 'dart:ui' as ui;
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/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/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/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -189,4 +188,6 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
} }
bool _shouldUseLocalAsset(BaseAsset asset) => bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited; asset.hasLocal &&
(!asset.hasRemote || !MetadataRepository.instance.appConfig.image.preferRemote) &&
!asset.isEdited;
@@ -1,9 +1,8 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/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/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/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.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'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@@ -105,7 +104,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return; return;
} }
final loadOriginal = Store.get(StoreKey.loadOriginal, false); final loadOriginal = MetadataRepository.instance.appConfig.image.loadOriginal;
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest( var request = this.request = LocalImageRequest(
localId: key.id, localId: key.id,
@@ -1,9 +1,8 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/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/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/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.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'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@@ -123,7 +122,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
edited: key.edited, edited: key.edited,
), ),
); );
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal); final loadOriginal = assetType == AssetType.image && MetadataRepository.instance.appConfig.image.loadOriginal;
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal); yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
if (!loadOriginal) { if (!loadOriginal) {
+1 -3
View File
@@ -11,12 +11,11 @@ import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.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/foreground_upload.service.dart';
import 'package:immich_mobile/services/secure_storage.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/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -144,7 +143,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
// Due to the flow of the code, this will always happen on first login // Due to the flow of the code, this will always happen on first login
user = serverUser; user = serverUser;
await Store.put(StoreKey.deviceId, deviceId); await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
} }
} on ApiException catch (error, stackTrace) { } on ApiException catch (error, stackTrace) {
if (error.code == 401) { if (error.code == 401) {
+43 -18
View File
@@ -15,37 +15,62 @@ class AuthGuard extends AutoRouteGuard {
final ApiService _apiService; final ApiService _apiService;
final AuthService _authService; final AuthService _authService;
final _log = Logger("AuthGuard"); final _log = Logger("AuthGuard");
bool _validateInFlight = false;
AuthGuard(this._apiService, this._authService); AuthGuard(this._apiService, this._authService);
@override @override
void onNavigation(NavigationResolver resolver, StackRouter router) async { void onNavigation(NavigationResolver resolver, StackRouter router) {
resolver.next(true); // 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 { try {
// Look in the store for an access token
Store.get(StoreKey.accessToken); Store.get(StoreKey.accessToken);
// Validate the access token with the server
final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login
_log.fine('User token is invalid. Redirecting to login');
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
}
} on StoreKeyNotFoundException catch (_) { } on StoreKeyNotFoundException catch (_) {
// If there is no access token, take us to the login page
_log.warning('No access token in the store.'); _log.warning('No access token in the store.');
resolver.next(false);
unawaited(router.replaceAll([const LoginRoute()])); unawaited(router.replaceAll([const LoginRoute()]));
return; return;
}
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 {
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;
}
_log.fine('User token is invalid. Redirecting to login');
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
}
} on ApiException catch (e) { } on ApiException catch (e) {
// On an unauthorized request, take us to the login page if (e.code != HttpStatus.unauthorized) {
if (e.code == HttpStatus.unauthorized) {
_log.warning("Unauthorized access token.");
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
return; return;
} }
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
_log.warning("Unauthorized access token.");
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
} catch (e) { } catch (e) {
// Otherwise, this is not fatal, but we still log the warning
_log.warning('Error validating access token from server: $e'); _log.warning('Error validating access token from server: $e');
} finally {
_validateInFlight = false;
} }
} }
} }
@@ -2,24 +2,9 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> { 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), selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, 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), selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true), enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false), syncAlbums<bool>(StoreKey.syncAlbums, null, false),
-1
View File
@@ -123,7 +123,6 @@ class AuthService {
_authRepository.clearLocalData(), _authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.autoEndpointSwitching), Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName), Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint), Store.delete(StoreKey.localEndpoint),
@@ -394,9 +394,13 @@ class BackgroundUploadService {
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString(); final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final fieldsMap = { final fieldsMap = {
'filename': originalFileName ?? filename, '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(), 'fileCreatedAt': createdAt.toUtc().toIso8601String(),
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false', 'isFavorite': isFavorite?.toString() ?? 'false',
@@ -5,6 +5,8 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -319,8 +321,12 @@ class ForegroundUploadService {
} }
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId);
final fields = { 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(), 'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(), 'isFavorite': asset.isFavorite.toString(),
@@ -426,6 +432,9 @@ class ForegroundUploadService {
final filename = p.basename(file.path); final filename = p.basename(file.path);
final fields = { 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(), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false', 'isFavorite': 'false',
+19 -4
View File
@@ -21,6 +21,7 @@ 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/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_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/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_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/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_action_button.widget.dart';
@@ -81,6 +82,7 @@ enum ActionButtonType {
moveToLockFolder, moveToLockFolder,
removeFromLockFolder, removeFromLockFolder,
removeFromAlbum, removeFromAlbum,
restoreTrash,
trash, trash,
deleteLocal, deleteLocal,
deletePermanent, deletePermanent,
@@ -112,12 +114,17 @@ enum ActionButtonType {
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.asset.hasRemote && // context.asset.hasRemote && //
context.isTrashEnabled, context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash,
ActionButtonType.deletePermanent => ActionButtonType.deletePermanent =>
context.isOwner && // context.isOwner && //
context.asset.hasRemote && // context.asset.hasRemote && //
!context.isTrashEnabled || (!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
context.isInLockedView,
ActionButtonType.delete => ActionButtonType.delete =>
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
@@ -201,6 +208,11 @@ enum ActionButtonType {
), ),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(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( ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source, source: context.source,
iconOnly: iconOnly, iconOnly: iconOnly,
@@ -292,6 +304,7 @@ enum ActionButtonType {
ActionButtonType.moveToLockFolder => 10, ActionButtonType.moveToLockFolder => 10,
ActionButtonType.deleteLocal => 10, ActionButtonType.deleteLocal => 10,
ActionButtonType.delete => 10, ActionButtonType.delete => 10,
ActionButtonType.restoreTrash => 10,
// 90: advancedInfo // 90: advancedInfo
ActionButtonType.advancedInfo => 90, ActionButtonType.advancedInfo => 90,
// 1: others // 1: others
@@ -309,6 +322,8 @@ class ActionButtonBuilder {
ActionButtonType.delete, ActionButtonType.delete,
ActionButtonType.archive, ActionButtonType.archive,
ActionButtonType.unarchive, ActionButtonType.unarchive,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
}; };
static List<Widget> build(ActionButtonContext context) { static List<Widget> build(ActionButtonContext context) {
+3 -2
View File
@@ -43,8 +43,9 @@ void configureFileDownloaderNotifications() {
abstract final class Bootstrap { abstract final class Bootstrap {
static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async { static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async {
final drift = Drift(); await configureSqliteCache();
final logDb = DriftLogger(); final drift = Drift.sqlite(await openSqliteConnection(name: 'immich'));
final logDb = DriftLogger.sqlite(await openSqliteConnection(name: 'immich_logs'));
final DriftStoreRepository storeRepo = DriftStoreRepository(drift); final DriftStoreRepository storeRepo = DriftStoreRepository(drift);
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates); await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
+8
View File
@@ -88,6 +88,14 @@ Future<void> _migrateTo26(Drift drift) async {
GroupAssetsBy.values, GroupAssetsBy.values,
); );
await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator); 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);
await migrator.complete(); await migrator.complete();
} }
@@ -32,7 +32,11 @@ class AdvancedSettings extends HookConsumerWidget {
final isManageMediaSupported = useState(false); final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false); final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index); final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
useValueChanged(
preferRemote.value,
(_, __) => ref.read(metadataProvider).write(.imagePreferRemote, preferRemote.value),
);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
final logLevel = Level.LEVELS[levelId.value].name; final logLevel = Level.LEVELS[levelId.value].name;
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.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/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@@ -12,7 +12,10 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal); final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal);
useValueChanged<bool, void>(isOriginal.value, (_, __) {
ref.read(metadataProvider).write(.imageLoadOriginal, isOriginal.value);
});
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1,18 +1,20 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/widgets/settings/settings_sub_title.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/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class ImageViewerTapToNavigateSetting extends HookConsumerWidget { class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
const ImageViewerTapToNavigateSetting({super.key}); const ImageViewerTapToNavigateSetting({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate); final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate);
useValueChanged<bool, void>(tapToNavigate.value, (_, __) {
ref.read(metadataProvider).write(.viewerTapToNavigate, tapToNavigate.value);
});
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -22,7 +24,6 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
valueNotifier: tapToNavigate, valueNotifier: tapToNavigate,
title: "setting_image_navigation_enable_title".tr(), title: "setting_image_navigation_enable_title".tr(),
subtitle: "setting_image_navigation_enable_subtitle".tr(), subtitle: "setting_image_navigation_enable_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
], ],
); );
@@ -1,20 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.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/widgets/settings/setting_group_title.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/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class VideoViewerSettings extends HookConsumerWidget { class VideoViewerSettings extends HookConsumerWidget {
const VideoViewerSettings({super.key}); const VideoViewerSettings({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final useLoopVideo = useAppSettingsState(AppSettingsEnum.loopVideo); final viewer = ref.read(appConfigProvider).viewer;
final useOriginalVideo = useAppSettingsState(AppSettingsEnum.loadOriginalVideo); final useAutoPlayVideo = useState(viewer.autoPlayVideo);
final useAutoPlayVideo = useAppSettingsState(AppSettingsEnum.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);
});
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -27,19 +37,16 @@ class VideoViewerSettings extends HookConsumerWidget {
valueNotifier: useAutoPlayVideo, valueNotifier: useAutoPlayVideo,
title: "setting_video_viewer_auto_play_title".t(context: context), title: "setting_video_viewer_auto_play_title".t(context: context),
subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context), subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useLoopVideo, valueNotifier: useLoopVideo,
title: "setting_video_viewer_looping_title".t(context: context), title: "setting_video_viewer_looping_title".t(context: context),
subtitle: "loop_videos_description".t(context: context), subtitle: "loop_videos_description".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useOriginalVideo, valueNotifier: useOriginalVideo,
title: "setting_video_viewer_original_video_title".t(context: context), title: "setting_video_viewer_original_video_title".t(context: context),
subtitle: "setting_video_viewer_original_video_subtitle".t(context: context), subtitle: "setting_video_viewer_original_video_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
], ],
); );
@@ -3,10 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.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/settings_button_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -16,9 +13,6 @@ class NotificationSetting extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final permissionService = ref.watch(notificationPermissionProvider); final permissionService = ref.watch(notificationPermissionProvider);
final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod);
final hasPermission = permissionService == PermissionStatus.granted; final hasPermission = permissionService == PermissionStatus.granted;
openAppNotificationSettings(BuildContext ctx) { openAppNotificationSettings(BuildContext ctx) {
@@ -41,8 +35,6 @@ class NotificationSetting extends HookConsumerWidget {
); );
} }
final String formattedValue = _formatSliderValue(sliderValue.value.toDouble());
final notificationSettings = [ final notificationSettings = [
if (!hasPermission) if (!hasPermission)
SettingsButtonListTile( SettingsButtonListTile(
@@ -57,32 +49,8 @@ class NotificationSetting extends HookConsumerWidget {
} }
}), }),
), ),
SettingsSliderListTile(
enabled: hasPermission,
valueNotifier: sliderValue,
text: 'setting_notifications_notify_failures_grace_period'.tr(namedArgs: {'duration': formattedValue}),
maxValue: 5.0,
noDivisons: 5,
label: formattedValue,
),
]; ];
return SettingsSubPageScaffold(settings: notificationSettings); return SettingsSubPageScaffold(settings: notificationSettings);
} }
} }
String _formatSliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();
} else if (v == 1.0) {
return 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '30'});
} else if (v == 2.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '2'});
} else if (v == 3.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '8'});
} else if (v == 4.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '24'});
} else {
return 'setting_notifications_notify_never'.tr();
}
}
+24 -16
View File
@@ -370,11 +370,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.32.1" version: "2.32.1"
drift_flutter: drift_sqlite_async:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift_flutter name: drift_sqlite_async
sha256: "887fdec622174dc7eaefd0048403e34ee07cc18626ac8a7544cc3b8a4a172166" sha256: "1b6e99562fc5d35fe5e3696741720a8aca47f4c3eee35d4b9b94be819f53a6f6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "0.3.0"
@@ -1619,30 +1619,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" version: "1.10.2"
sqlcipher_flutter_libs:
dependency: transitive
description:
name: sqlcipher_flutter_libs
sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929"
url: "https://pub.dev"
source: hosted
version: "0.7.0+eol"
sqlite3: sqlite3:
dependency: transitive dependency: "direct main"
description: description:
name: sqlite3 name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5" sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.1" version: "3.3.1"
sqlite3_flutter_libs: sqlite3_connection_pool:
dependency: transitive dependency: transitive
description: description:
name: sqlite3_flutter_libs name: sqlite3_connection_pool
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" sha256: "90b25972c7699d84da97df1c5919804275560b4ab8a158bbec890434b9718f65"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0+eol" version: "0.2.4"
sqlite3_web:
dependency: transitive
description:
name: sqlite3_web
sha256: d876398a9f2cbf115d93fc34901f8fa129b58b13b5fa9377156ed3a9a05695e3
url: "https://pub.dev"
source: hosted
version: "0.7.1"
sqlite_async:
dependency: "direct main"
description:
name: sqlite_async
sha256: "4c243c5386eba3a7102f98999388a7e0a7f2632e4e06dafb3b4f5a44170a26f6"
url: "https://pub.dev"
source: hosted
version: "0.14.1"
sqlparser: sqlparser:
dependency: transitive dependency: transitive
description: description:
+3 -1
View File
@@ -19,7 +19,7 @@ dependencies:
crypto: ^3.0.7 crypto: ^3.0.7
device_info_plus: ^12.4.0 device_info_plus: ^12.4.0
drift: ^2.32.1 drift: ^2.32.1
drift_flutter: ^0.3.0 drift_sqlite_async: 0.3.0
dynamic_color: ^1.8.1 dynamic_color: ^1.8.1
easy_localization: ^3.0.8 easy_localization: ^3.0.8
ffi: ^2.2.0 ffi: ^2.2.0
@@ -66,6 +66,8 @@ dependencies:
share_plus: ^10.1.4 share_plus: ^10.1.4
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
stream_transform: ^2.1.1 stream_transform: ^2.1.1
sqlite3: ^3.3.1
sqlite_async: 0.14.1
thumbhash: 0.1.0+1 thumbhash: 0.1.0+1
timezone: ^0.9.4 timezone: ^0.9.4
url_launcher: ^6.3.2 url_launcher: ^6.3.2
@@ -131,7 +131,7 @@ void main() {
durationMs: 0, durationMs: 0,
orientation: 0, orientation: 0,
isFavorite: false, isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image playbackStyle: PlatformAssetPlaybackStyle.image,
); );
final assetsToRestore = [LocalAssetStub.image1]; final assetsToRestore = [LocalAssetStub.image1];
@@ -215,7 +215,7 @@ void main() {
isFavorite: false, isFavorite: false,
createdAt: 1700000000, createdAt: 1700000000,
updatedAt: 1732000000, updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image playbackStyle: PlatformAssetPlaybackStyle.image,
); );
final localAsset = platformAsset.toLocalAsset(); final localAsset = platformAsset.toLocalAsset();
@@ -9,9 +9,8 @@ import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart'; import '../../infrastructure/repository.mock.dart';
const _kAccessToken = '#ThisIsAToken'; const _kAccessToken = '#ThisIsAToken';
const _kBackgroundBackup = false; const _kEnableBackup = false;
const _kVersion = 2; const _kVersion = 2;
final _kBackupFailedSince = DateTime.utc(2023);
void main() { void main() {
late StoreService sut; late StoreService sut;
@@ -24,15 +23,13 @@ void main() {
// For generics, we need to provide fallback to each concrete type to avoid runtime errors // For generics, we need to provide fallback to each concrete type to avoid runtime errors
registerFallbackValue(StoreKey.accessToken); registerFallbackValue(StoreKey.accessToken);
registerFallbackValue(StoreKey.backupTriggerDelay); registerFallbackValue(StoreKey.backupTriggerDelay);
registerFallbackValue(StoreKey.backgroundBackup); registerFallbackValue(StoreKey.enableBackup);
registerFallbackValue(StoreKey.backupFailedSince);
when(() => mockDriftStoreRepo.getAll()).thenAnswer( when(() => mockDriftStoreRepo.getAll()).thenAnswer(
(_) async => [ (_) async => [
const StoreDto(StoreKey.accessToken, _kAccessToken), const StoreDto(StoreKey.accessToken, _kAccessToken),
const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup), const StoreDto(StoreKey.enableBackup, _kEnableBackup),
const StoreDto(StoreKey.version, _kVersion), const StoreDto(StoreKey.version, _kVersion),
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
], ],
); );
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream); when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
@@ -49,9 +46,8 @@ void main() {
test('Populates the internal cache on init', () { test('Populates the internal cache on init', () {
verify(() => mockDriftStoreRepo.getAll()).called(1); verify(() => mockDriftStoreRepo.getAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup);
expect(sut.tryGet(StoreKey.version), _kVersion); expect(sut.tryGet(StoreKey.version), _kVersion);
expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince);
// Other keys should be null // Other keys should be null
expect(sut.tryGet(StoreKey.currentUser), isNull); expect(sut.tryGet(StoreKey.currentUser), isNull);
}); });
@@ -151,9 +147,8 @@ void main() {
await sut.clear(); await sut.clear();
verify(() => mockDriftStoreRepo.deleteAll()).called(1); verify(() => mockDriftStoreRepo.deleteAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), isNull); expect(sut.tryGet(StoreKey.accessToken), isNull);
expect(sut.tryGet(StoreKey.backgroundBackup), isNull); expect(sut.tryGet(StoreKey.enableBackup), isNull);
expect(sut.tryGet(StoreKey.version), isNull); expect(sut.tryGet(StoreKey.version), isNull);
expect(sut.tryGet(StoreKey.backupFailedSince), isNull);
}); });
}); });
} }
@@ -12,9 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import '../../fixtures/user.stub.dart'; import '../../fixtures/user.stub.dart';
const _kTestAccessToken = "#TestToken"; const _kTestAccessToken = "#TestToken";
final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45);
const _kTestVersion = 10; const _kTestVersion = 10;
const _kTestBackupRequireWifi = false; const _kTestBackupRequireCharging = false;
final _kTestUser = UserStub.admin; final _kTestUser = UserStub.admin;
Future<void> _populateStore(Drift db) async { Future<void> _populateStore(Drift db) async {
@@ -22,16 +21,8 @@ Future<void> _populateStore(Drift db) async {
batch.insert( batch.insert(
db.storeEntity, db.storeEntity,
StoreEntityCompanion( StoreEntityCompanion(
id: Value(StoreKey.backupRequireWifi.id), id: Value(StoreKey.backupRequireCharging.id),
intValue: const Value(_kTestBackupRequireWifi ? 1 : 0), intValue: const Value(_kTestBackupRequireCharging ? 1 : 0),
stringValue: const Value(null),
),
);
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupFailedSince.id),
intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch),
stringValue: const Value(null), stringValue: const Value(null),
), ),
); );
@@ -84,20 +75,12 @@ void main() {
expect(accessToken, _kTestAccessToken); expect(accessToken, _kTestAccessToken);
}); });
test('converts datetime', () async {
DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
expect(backupFailedSince, isNull);
await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed);
backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
expect(backupFailedSince, _kTestBackupFailed);
});
test('converts bool', () async { test('converts bool', () async {
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireWifi, isNull); expect(backupRequireCharging, isNull);
await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi); await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging);
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireWifi, _kTestBackupRequireWifi); expect(backupRequireCharging, _kTestBackupRequireCharging);
}); });
test('converts user', () async { test('converts user', () async {
@@ -115,11 +98,11 @@ void main() {
}); });
test('delete()', () async { test('delete()', () async {
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireWifi, isFalse); expect(backupRequireCharging, isFalse);
await sut.delete(StoreKey.backupRequireWifi); await sut.delete(StoreKey.backupRequireCharging);
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireWifi, isNull); expect(backupRequireCharging, isNull);
}); });
test('deleteAll()', () async { test('deleteAll()', () async {
@@ -164,14 +147,12 @@ void main() {
emitsInOrder([ emitsInOrder([
[ [
const StoreDto<Object>(StoreKey.version, _kTestVersion), const StoreDto<Object>(StoreKey.version, _kTestVersion),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed), const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken), const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
], ],
[ [
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10), const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed), const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken), const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
], ],
]), ]),
@@ -79,7 +79,6 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.system); expect(sut.appConfig.theme.mode, ThemeMode.system);
await MetadataRepository.refresh(); await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.dark); expect(sut.appConfig.theme.mode, ThemeMode.dark);
}); });
@@ -90,7 +89,6 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.dark); expect(sut.appConfig.theme.mode, ThemeMode.dark);
await MetadataRepository.refresh(); await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system); expect(sut.appConfig.theme.mode, ThemeMode.system);
}); });
@@ -135,5 +133,4 @@ void main() {
await expectation; await expectation;
}); });
}); });
} }
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/action_button.utils.dart';
LocalAsset createLocalAsset({ LocalAsset createLocalAsset({
@@ -37,6 +38,7 @@ RemoteAsset createRemoteAsset({
DateTime? updatedAt, DateTime? updatedAt,
DateTime? uploadedAt, DateTime? uploadedAt,
bool isFavorite = false, bool isFavorite = false,
DateTime? deletedAt,
}) { }) {
return RemoteAsset( return RemoteAsset(
id: 'remote-id', id: 'remote-id',
@@ -50,6 +52,7 @@ RemoteAsset createRemoteAsset({
uploadedAt: uploadedAt ?? DateTime.now(), uploadedAt: uploadedAt ?? DateTime.now(),
isFavorite: isFavorite, isFavorite: isFavorite,
isEdited: false, isEdited: false,
deletedAt: deletedAt,
); );
} }
@@ -458,6 +461,62 @@ void main() {
expect(ActionButtonType.trash.shouldShow(context), isFalse); expect(ActionButtonType.trash.shouldShow(context), isFalse);
}); });
test('should not show when asset is already trashed', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
});
group('restoreTrash button', () {
test('should show when owner, not locked, has remote, and is in trash timeline', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue);
});
test('should not show when not in trash timeline', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
timelineOrigin: TimelineOrigin.main,
);
expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse);
});
}); });
group('deletePermanent button', () { group('deletePermanent button', () {
@@ -494,6 +553,24 @@ void main() {
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse); expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
}); });
test('should show when asset is trashed even with trash enabled', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
}); });
group('delete button', () { group('delete button', () {
+90 -145
View File
@@ -6,7 +6,7 @@ settings:
injectWorkspacePackages: true injectWorkspacePackages: true
overrides: overrides:
canvas: 2.11.2 canvas: 3.2.3
sharp: ^0.34.5 sharp: ^0.34.5
webpackbar: ^7.0.0 webpackbar: ^7.0.0
@@ -198,7 +198,7 @@ importers:
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: vitest:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/cli: packages/cli:
dependencies: dependencies:
@@ -289,7 +289,7 @@ importers:
version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: vitest:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-fetch-mock: vitest-fetch-mock:
specifier: ^0.4.0 specifier: ^0.4.0
version: 0.4.5(vitest@4.1.5) version: 0.4.5(vitest@4.1.5)
@@ -663,7 +663,7 @@ importers:
version: 13.15.10 version: 13.15.10
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
eslint: eslint:
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.2.1(jiti@2.6.1) version: 10.2.1(jiti@2.6.1)
@@ -717,7 +717,7 @@ importers:
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: vitest:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
web: web:
dependencies: dependencies:
@@ -973,7 +973,7 @@ importers:
version: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) version: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: vitest:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages: packages:
@@ -6182,9 +6182,9 @@ packages:
caniuse-lite@1.0.30001790: caniuse-lite@1.0.30001790:
resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==}
canvas@2.11.2: canvas@3.2.3:
resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==}
engines: {node: '>=6'} engines: {node: ^18.12.0 || >= 20.9.0}
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -6964,10 +6964,6 @@ packages:
decode-named-character-reference@1.2.0: decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
decompress-response@4.2.1:
resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==}
engines: {node: '>=8'}
decompress-response@6.0.0: decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -7567,6 +7563,10 @@ packages:
resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==} resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0: expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -7866,6 +7866,9 @@ packages:
get-tsconfig@4.13.0: get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
github-slugger@1.5.0: github-slugger@1.5.0:
resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==}
@@ -8591,7 +8594,7 @@ packages:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
canvas: 2.11.2 canvas: 3.2.3
peerDependenciesMeta: peerDependenciesMeta:
canvas: canvas:
optional: true optional: true
@@ -9308,10 +9311,6 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
mimic-response@2.1.0:
resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==}
engines: {node: '>=8'}
mimic-response@3.1.0: mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -9479,6 +9478,9 @@ packages:
engines: {node: ^18 || >=20} engines: {node: ^18 || >=20}
hasBin: true hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare-lite@1.4.0: natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
@@ -9552,6 +9554,10 @@ packages:
no-case@3.0.4: no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-abi@3.92.0:
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
engines: {node: '>=10'}
node-abort-controller@3.1.1: node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -10499,6 +10505,12 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'} engines: {node: '>=20'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -11176,8 +11188,8 @@ packages:
simple-concat@1.0.1: simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@3.1.1: simple-get@4.0.1:
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-icons@16.17.0: simple-icons@16.17.0:
resolution: {integrity: sha512-bRrGtzM6NLgxeMWmRcfDdrRksECk101lRrCn6jjj6qzUB6lQ+E5smnr52rqS1kLPmbLpS/g6iF463j50M4BT7A==} resolution: {integrity: sha512-bRrGtzM6NLgxeMWmRcfDdrRksECk101lRrCn6jjj6qzUB6lQ+E5smnr52rqS1kLPmbLpS/g6iF463j50M4BT7A==}
@@ -11886,6 +11898,9 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tweetnacl@0.14.5: tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
@@ -15799,22 +15814,6 @@ snapshots:
'@mapbox/mapbox-gl-rtl-text@0.4.0': {} '@mapbox/mapbox-gl-rtl-text@0.4.0': {}
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.1.2
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
tar: 6.2.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
dependencies: dependencies:
detect-libc: 2.1.2 detect-libc: 2.1.2
@@ -17248,7 +17247,7 @@ snapshots:
svelte: 5.55.2 svelte: 5.55.2
optionalDependencies: optionalDependencies:
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies: dependencies:
@@ -17964,7 +17963,7 @@ snapshots:
'@vercel/oidc@3.0.5': {} '@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
@@ -17979,7 +17978,7 @@ snapshots:
std-env: 3.10.0 std-env: 3.10.0
test-exclude: 7.0.2 test-exclude: 7.0.2
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -17995,7 +17994,7 @@ snapshots:
obug: 2.1.1 obug: 2.1.1
std-env: 4.1.0 std-env: 4.1.0
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
dependencies: dependencies:
@@ -18762,24 +18761,10 @@ snapshots:
caniuse-lite@1.0.30001790: {} caniuse-lite@1.0.30001790: {}
canvas@2.11.2: canvas@3.2.3:
dependencies: dependencies:
'@mapbox/node-pre-gyp': 1.0.11 node-addon-api: 7.1.1
nan: 2.26.2 prebuild-install: 7.1.3
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
canvas@2.11.2(encoding@0.1.13):
dependencies:
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
nan: 2.26.2
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true optional: true
ccount@2.0.1: {} ccount@2.0.1: {}
@@ -19582,11 +19567,6 @@ snapshots:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
decompress-response@4.2.1:
dependencies:
mimic-response: 2.1.0
optional: true
decompress-response@6.0.0: decompress-response@6.0.0:
dependencies: dependencies:
mimic-response: 3.1.0 mimic-response: 3.1.0
@@ -20331,6 +20311,9 @@ snapshots:
optionalDependencies: optionalDependencies:
exiftool-vendored.exe: 13.58.0 exiftool-vendored.exe: 13.58.0
expand-template@2.0.3:
optional: true
expect-type@1.3.0: {} expect-type@1.3.0: {}
exponential-backoff@3.1.3: {} exponential-backoff@3.1.3: {}
@@ -20416,11 +20399,10 @@ snapshots:
fabric@7.3.1: fabric@7.3.1:
optionalDependencies: optionalDependencies:
canvas: 2.11.2 canvas: 3.2.3
jsdom: 26.1.0(canvas@2.11.2) jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- encoding
- supports-color - supports-color
- utf-8-validate - utf-8-validate
@@ -20712,6 +20694,9 @@ snapshots:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 resolve-pkg-maps: 1.0.0
github-from-package@0.0.0:
optional: true
github-slugger@1.5.0: {} github-slugger@1.5.0: {}
gl-matrix@3.4.4: {} gl-matrix@3.4.4: {}
@@ -21533,7 +21518,7 @@ snapshots:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)): jsdom@26.1.0(canvas@3.2.3):
dependencies: dependencies:
cssstyle: 4.6.0 cssstyle: 4.6.0
data-urls: 5.0.0 data-urls: 5.0.0
@@ -21556,37 +21541,7 @@ snapshots:
ws: 8.20.0 ws: 8.20.0
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0
optionalDependencies: optionalDependencies:
canvas: 2.11.2(encoding@0.1.13) canvas: 3.2.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
optional: true
jsdom@26.1.0(canvas@2.11.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.23
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 5.1.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
ws: 8.20.0
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 2.11.2
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- supports-color - supports-color
@@ -22589,9 +22544,6 @@ snapshots:
mimic-function@5.0.1: {} mimic-function@5.0.1: {}
mimic-response@2.1.0:
optional: true
mimic-response@3.1.0: {} mimic-response@3.1.0: {}
mimic-response@4.0.0: {} mimic-response@4.0.0: {}
@@ -22745,6 +22697,9 @@ snapshots:
nanoid@5.1.9: {} nanoid@5.1.9: {}
napi-build-utils@2.0.0:
optional: true
natural-compare-lite@1.4.0: {} natural-compare-lite@1.4.0: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
@@ -22817,6 +22772,11 @@ snapshots:
lower-case: 2.0.2 lower-case: 2.0.2
tslib: 2.8.1 tslib: 2.8.1
node-abi@3.92.0:
dependencies:
semver: 7.7.4
optional: true
node-abort-controller@3.1.1: {} node-abort-controller@3.1.1: {}
node-addon-api@4.3.0: {} node-addon-api@4.3.0: {}
@@ -22837,11 +22797,6 @@ snapshots:
emojilib: 2.4.0 emojilib: 2.4.0
skin-tone: 2.0.0 skin-tone: 2.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
optional: true
node-fetch@2.7.0(encoding@0.1.13): node-fetch@2.7.0(encoding@0.1.13):
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
@@ -23808,6 +23763,22 @@ snapshots:
powershell-utils@0.1.0: {} powershell-utils@0.1.0: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.92.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
optional: true
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1: prettier-linter-helpers@1.0.1:
@@ -24731,9 +24702,9 @@ snapshots:
simple-concat@1.0.1: simple-concat@1.0.1:
optional: true optional: true
simple-get@3.1.1: simple-get@4.0.1:
dependencies: dependencies:
decompress-response: 4.2.1 decompress-response: 6.0.0
once: 1.4.0 once: 1.4.0
simple-concat: 1.0.1 simple-concat: 1.0.1
optional: true optional: true
@@ -25575,6 +25546,11 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
optional: true
tweetnacl@0.14.5: {} tweetnacl@0.14.5: {}
type-check@0.4.0: type-check@0.4.0:
@@ -25984,9 +25960,9 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@4.1.5): vitest-fetch-mock@0.4.5(vitest@4.1.5):
dependencies: dependencies:
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.4 '@vitest/expect': 3.2.4
@@ -26015,7 +25991,7 @@ snapshots:
'@types/debug': 4.1.12 '@types/debug': 4.1.12
'@types/node': 24.12.2 '@types/node': 24.12.2
happy-dom: 20.9.0 happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2) jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less
@@ -26030,7 +26006,7 @@ snapshots:
- tsx - tsx
- yaml - yaml
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
'@vitest/expect': 4.1.5 '@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -26057,42 +26033,11 @@ snapshots:
'@types/node': 24.12.2 '@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5) '@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0 happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
'@vitest/spy': 4.1.5
'@vitest/utils': 4.1.5
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- msw
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
'@vitest/expect': 4.1.5 '@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -26119,7 +26064,7 @@ snapshots:
'@types/node': 25.6.0 '@types/node': 25.6.0
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5) '@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0 happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2) jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
+1 -1
View File
@@ -28,7 +28,7 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
- bcrypt - bcrypt
overrides: overrides:
canvas: 2.11.2 canvas: 3.2.3
sharp: ^0.34.5 sharp: ^0.34.5
# pending docusaurus 3.10.1 # pending docusaurus 3.10.1
webpackbar: ^7.0.0 webpackbar: ^7.0.0
+7 -4
View File
@@ -171,8 +171,8 @@ export class JobRepository {
options: this.getJobOptions(item) || undefined, options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined }; } as JobItem & { data: any; options: JobsOptions | undefined };
if (job.options?.jobId) { if (job.options?.jobId || job.options?.deduplication) {
// need to use add() instead of addBulk() for jobId deduplication // need to use add() instead of addBulk() for jobId/deduplication to take effect
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options)); promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
} else { } else {
itemsByQueue[queueName] = itemsByQueue[queueName] || []; itemsByQueue[queueName] = itemsByQueue[queueName] || [];
@@ -230,10 +230,13 @@ export class JobRepository {
return { priority: 1 }; return { priority: 1 };
} }
case JobName.FacialRecognitionQueueAll: { case JobName.FacialRecognitionQueueAll: {
return { jobId: JobName.FacialRecognitionQueueAll }; return { deduplication: { id: JobName.FacialRecognitionQueueAll } };
} }
case JobName.VersionCheck: { case JobName.VersionCheck: {
return { jobId: JobName.VersionCheck }; return { deduplication: { id: JobName.VersionCheck } };
}
case JobName.DatabaseBackup: {
return { deduplication: { id: JobName.DatabaseBackup } };
} }
default: { default: {
return null; return null;
@@ -132,7 +132,7 @@ export class MachineLearningRepository {
private async check(url: string) { private async check(url: string) {
let healthy = false; let healthy = false;
try { try {
const response = await fetch(new URL('/ping', url), { const response = await fetch(new URL('ping', url), {
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout), signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
}); });
if (response.ok) { if (response.ok) {
@@ -170,7 +170,7 @@ export class MachineLearningRepository {
...this.config.urls.filter((url) => !this.isHealthy(url)), ...this.config.urls.filter((url) => !this.isHealthy(url)),
]) { ]) {
try { try {
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); const response = await fetch(new URL('predict', url), { method: 'POST', body: formData });
if (response.ok) { if (response.ok) {
this.setHealthy(url, true); this.setHealthy(url, true);
return response.json(); return response.json();
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import { BinaryField, DefaultReadTaskOptions, ExifTool, ReadTaskOptions, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz'; import geotz from 'geo-tz';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
@@ -89,7 +89,7 @@ export class MetadataRepository {
geoTz: (lat, lon) => geotz.find(lat, lon)[0], geoTz: (lat, lon) => geotz.find(lat, lon)[0],
geolocation: true, geolocation: true,
// Enable exiftool LFS to parse metadata for files larger than 2GB. // Enable exiftool LFS to parse metadata for files larger than 2GB.
readArgs: ['-api', 'largefilesupport=1'], readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'],
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
taskTimeoutMillis: 2 * 60 * 1000, taskTimeoutMillis: 2 * 60 * 1000,
}); });
@@ -107,8 +107,8 @@ export class MetadataRepository {
} }
readTags(path: string): Promise<ImmichTags> { readTags(path: string): Promise<ImmichTags> {
const args = mimeTypes.isVideo(path) ? ['-ee'] : []; const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined;
return this.exiftool.read(path, { readArgs: args }).catch((error) => { return this.exiftool.read(path, options).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
return {}; return {};
}) as Promise<ImmichTags>; }) as Promise<ImmichTags>;
@@ -212,16 +212,10 @@
}; };
} }
try { return {
return { fileCreatedAfter: dateAfter?.toUTC().toISO(),
fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined, fileCreatedBefore: dateBefore?.toUTC().toISO(),
fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined, };
};
} catch {
$mapSettings.dateAfter = '';
$mapSettings.dateBefore = '';
return {};
}
} }
async function loadMapMarkers() { async function loadMapMarkers() {
@@ -237,7 +231,7 @@
{ {
isArchived: includeArchived || undefined, isArchived: includeArchived || undefined,
isFavorite: onlyFavorites || undefined, isFavorite: onlyFavorites || undefined,
fileCreatedAfter: fileCreatedAfter || undefined, fileCreatedAfter,
fileCreatedBefore, fileCreatedBefore,
withPartners: withPartners || undefined, withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined, withSharedAlbums: withSharedAlbums || undefined,
+27 -38
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import type { MapSettings } from '$lib/stores/preferences.store'; import type { MapSettings } from '$lib/stores/preferences.store';
import { Button, Field, FormModal, Select, Stack, Switch } from '@immich/ui'; import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
@@ -41,29 +40,21 @@
{#if customDateRange} {#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4"> <div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8"> <Field label={$t('date_after')}>
<label class="shrink-0 text-sm immich-form-label" for="date-after">{$t('date_after')}</label> <DatePicker bind:value={settings.dateAfter} maxDate={settings.dateBefore} />
<DateInput </Field>
class="immich-form-input w-40" <Field label={$t('date_before')}>
type="date" <DatePicker bind:value={settings.dateBefore} />
id="date-after" </Field>
max={settings.dateBefore} <div class="flex justify-center">
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="shrink-0 text-sm immich-form-label" for="date-before">{$t('date_before')}</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<Button <Button
color="primary" color="primary"
size="small" size="small"
variant="ghost" variant="ghost"
onclick={() => { onclick={() => {
customDateRange = false; customDateRange = false;
settings.dateAfter = ''; settings.dateAfter = undefined;
settings.dateBefore = ''; settings.dateBefore = undefined;
}} }}
> >
{$t('remove_custom_date_range')} {$t('remove_custom_date_range')}
@@ -71,7 +62,7 @@
</div> </div>
</div> </div>
{:else} {:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1"> <div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-2">
<Field label={$t('date_range')}> <Field label={$t('date_range')}>
<Select <Select
bind:value={settings.relativeDate} bind:value={settings.relativeDate}
@@ -82,40 +73,38 @@
}, },
{ {
label: $t('past_durations.hours', { values: { hours: 24 } }), label: $t('past_durations.hours', { values: { hours: 24 } }),
value: Duration.fromObject({ hours: 24 }).toISO() || '', value: Duration.fromObject({ hours: 24 }).toISO(),
}, },
{ {
label: $t('past_durations.days', { values: { days: 7 } }), label: $t('past_durations.days', { values: { days: 7 } }),
value: Duration.fromObject({ days: 7 }).toISO() || '', value: Duration.fromObject({ days: 7 }).toISO(),
}, },
{ {
label: $t('past_durations.days', { values: { days: 30 } }), label: $t('past_durations.days', { values: { days: 30 } }),
value: Duration.fromObject({ days: 30 }).toISO() || '', value: Duration.fromObject({ days: 30 }).toISO(),
}, },
{ {
label: $t('past_durations.years', { values: { years: 1 } }), label: $t('past_durations.years', { values: { years: 1 } }),
value: Duration.fromObject({ years: 1 }).toISO() || '', value: Duration.fromObject({ years: 1 }).toISO(),
}, },
{ {
label: $t('past_durations.years', { values: { years: 3 } }), label: $t('past_durations.years', { values: { years: 3 } }),
value: Duration.fromObject({ years: 3 }).toISO() || '', value: Duration.fromObject({ years: 3 }).toISO(),
}, },
]} ]}
/> />
</Field> </Field>
<div class="text-xs"> <Button
<Button color="primary"
color="primary" size="small"
size="small" variant="ghost"
variant="ghost" onclick={() => {
onclick={() => { customDateRange = true;
customDateRange = true; settings.relativeDate = '';
settings.relativeDate = ''; }}
}} >
> {$t('use_custom_date_range')}
{$t('use_custom_date_range')} </Button>
</Button>
</div>
</div> </div>
{/if} {/if}
</Stack> </Stack>
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import { handleUpdatePersonBirthDate } from '$lib/services/person.service'; import { handleUpdatePersonBirthDate } from '$lib/services/person.service';
import { type PersonResponseDto } from '@immich/sdk'; import { type PersonResponseDto } from '@immich/sdk';
import { Button, FormModal, Text } from '@immich/ui'; import { Button, DatePicker, Field, FormModal, HelperText } from '@immich/ui';
import { mdiCake } from '@mdi/js'; import { mdiCake } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
@@ -12,32 +12,25 @@
}; };
let { person, onClose }: Props = $props(); let { person, onClose }: Props = $props();
let birthDate = $derived(person.birthDate ?? ''); let birthDate = $derived(person.birthDate ? DateTime.fromISO(person.birthDate) : undefined);
const onSubmit = async () => { const onSubmit = async () => {
const success = await handleUpdatePersonBirthDate(person, birthDate); const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? '');
if (success) { if (success) {
onClose(); onClose();
} }
}; };
const todayFormatted = new Date().toISOString().split('T')[0];
</script> </script>
<FormModal title={$t('set_date_of_birth')} size="small" icon={mdiCake} {onClose} {onSubmit}> <FormModal title={$t('set_date_of_birth')} size="small" icon={mdiCake} {onClose} {onSubmit}>
<Text size="small">{$t('birthdate_set_description')}</Text> <div class="my-2 flex flex-col gap-2">
<div class="my-4 flex flex-col gap-2"> <Field label={$t('date_of_birth')}>
<DateInput <DatePicker bind:value={birthDate} maxDate={DateTime.now()} />
class="immich-form-input" <HelperText>{$t('birthdate_set_description')}</HelperText>
id="birthDate" </Field>
name="birthDate"
type="date"
bind:value={birthDate}
max={todayFormatted}
/>
{#if person.birthDate} {#if person.birthDate}
<div class="flex justify-end"> <div class="flex justify-end">
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = '')}> <Button shape="round" color="secondary" size="small" onclick={() => (birthDate = undefined)}>
{$t('clear')} {$t('clear')}
</Button> </Button>
</div> </div>
+3 -4
View File
@@ -1,3 +1,4 @@
import type { DateTime } from 'luxon';
import { persisted } from 'svelte-persisted-store'; import { persisted } from 'svelte-persisted-store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { defaultLang } from '$lib/constants'; import { defaultLang } from '$lib/constants';
@@ -26,8 +27,8 @@ export interface MapSettings {
withPartners: boolean; withPartners: boolean;
withSharedAlbums: boolean; withSharedAlbums: boolean;
relativeDate: string; relativeDate: string;
dateAfter: string; dateAfter?: DateTime<true>;
dateBefore: string; dateBefore?: DateTime<true>;
} }
const defaultMapSettings = { const defaultMapSettings = {
@@ -37,8 +38,6 @@ const defaultMapSettings = {
withPartners: false, withPartners: false,
withSharedAlbums: false, withSharedAlbums: false,
relativeDate: '', relativeDate: '',
dateAfter: '',
dateBefore: '',
}; };
const persistedObject = <T>(key: string, defaults: T) => const persistedObject = <T>(key: string, defaults: T) =>
+33 -4
View File
@@ -4,15 +4,18 @@
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte'; import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js'; import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import Portal from '$lib/elements/Portal.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -40,6 +43,15 @@
} }
} }
}; };
const onViewAsset = async (id: string) => {
const asset = await getAssetInfo({ ...authManager.params, id });
assetViewerManager.setAsset(asset);
};
const assetCursor = $derived({
current: assetViewerManager.asset!,
});
</script> </script>
<OnEvents {onPersonThumbnailReady} /> <OnEvents {onPersonThumbnailReady} />
@@ -122,15 +134,20 @@
draggable="false">{$t('view_all')}</a draggable="false">{$t('view_all')}</a
> >
</div> </div>
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42"> <div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
{#each recents as item (item.data.id)} {#each recents as item (item.data.id)}
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false"> <button
type="button"
class="relative h-full flex-auto"
onclick={() => onViewAsset(item.data.id)}
draggable="false"
>
<img <img
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })} src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))} alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover" class="size-full min-w-max rounded-xl object-cover"
/> />
</a> </button>
{/each} {/each}
</div> </div>
</div> </div>
@@ -140,3 +157,15 @@
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" /> <EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if} {/if}
</UserPageLayout> </UserPageLayout>
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
cursor={assetCursor}
showNavigation={false}
onClose={() => assetViewerManager.showAssetViewer(false)}
/>
</Portal>
{/await}
{/if}