Compare commits

...

20 Commits

Author SHA1 Message Date
Ben Beckford 37beb21aaa fix(web): recently added ux 2026-05-14 15:35:58 -07: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
55 changed files with 891 additions and 511 deletions
-2
View File
@@ -116,7 +116,6 @@ jobs:
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Android SDK
@@ -189,7 +188,6 @@ jobs:
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
build-sign-ios:
+1 -1
View File
@@ -565,7 +565,7 @@ jobs:
run: mise //mobile:codegen:translation
- name: Run tests
run: mise //mobile:test -j 1
run: mise //mobile:test
ml-unit-tests:
name: Unit Test ML
+30 -30
View File
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.5"
constraints = "4.52.5"
version = "4.52.7"
constraints = "4.52.7"
hashes = [
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
]
}
@@ -5,7 +5,7 @@ terraform {
required_providers {
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.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.5"
constraints = "4.52.5"
version = "4.52.7"
constraints = "4.52.7"
hashes = [
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers {
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 |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `grant-admin` | Grant admin privileges to a user (by email) |
| `revoke-admin` | Revoke admin privileges from a user (by email) |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| `schema-check` | Verify database migrations and check for schema drift |
## How to run a command
@@ -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
```
@@ -126,3 +145,12 @@ immich-admin change-media-location
Database file paths updated successfully! 🎉
...
```
Schema Check
```
immich-admin schema-check
Migrations are up to date
No schema drift detected
```
+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
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**"
@@ -74,6 +74,7 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
<details>
<summary>Updating Immich using Container Manager</summary>
Check the post installation and upgrade instructions at the links above before proceeding with this section.
## Step 1. Backup
@@ -110,7 +111,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
## 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.
![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)
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>
+3 -4
View File
@@ -885,15 +885,13 @@
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
"dark_theme": "Switch to dark theme",
"date": "Date",
"date_after": "Date after",
"date_and_time": "Date and Time",
"date_before": "Date before",
"date_format": "E, LLL d, y • h:mm a",
"date_of_birth": "Date of birth",
"date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range",
"day": "Day",
@@ -1403,6 +1401,7 @@
"link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account",
"list": "List",
"live": "Live",
"loading": "Loading",
"loading_search_results_failed": "Loading search results failed",
"local": "Local",
@@ -1582,8 +1581,8 @@
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model",
"month": "Month",
"monthly_title_text_date_format": "MMMM y",
"more": "More",
"motion": "Motion",
"move": "Move",
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder",
@@ -11,6 +11,7 @@ class RemoteAsset extends BaseAsset {
final String ownerId;
final String? stackId;
final DateTime? uploadedAt;
final DateTime? deletedAt;
const RemoteAsset({
required this.id,
@@ -31,6 +32,7 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
}) : localAssetId = localId;
@override
@@ -48,6 +50,8 @@ class RemoteAsset extends BaseAsset {
@override
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
bool get isTrashed => deletedAt != null;
@override
String toString() {
return '''Asset {
@@ -86,7 +90,8 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash &&
visibility == other.visibility &&
stackId == other.stackId &&
uploadedAt == other.uploadedAt;
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
}
@override
@@ -98,7 +103,8 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^
visibility.hashCode ^
stackId.hashCode ^
uploadedAt.hashCode;
uploadedAt.hashCode ^
deletedAt.hashCode;
RemoteAsset copyWith({
String? id,
@@ -119,6 +125,7 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -139,6 +146,7 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}
@@ -156,6 +164,7 @@ class RemoteAssetExif extends RemoteAsset {
required super.createdAt,
required super.updatedAt,
super.uploadedAt,
super.deletedAt,
super.width,
super.height,
super.durationMs,
@@ -193,6 +202,7 @@ class RemoteAssetExif extends RemoteAsset {
DateTime? createdAt,
DateTime? updatedAt,
DateTime? uploadedAt,
DateTime? deletedAt,
int? width,
int? height,
int? durationMs,
@@ -214,6 +224,7 @@ class RemoteAssetExif extends RemoteAsset {
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width,
height: height ?? this.height,
durationMs: durationMs ?? this.durationMs,
@@ -1,26 +1,41 @@
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/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
class AppConfig {
final ThemeConfig theme;
final CleanupConfig cleanup;
final MapConfig map;
final TimelineConfig timeline;
final ImageConfig image;
final ViewerConfig viewer;
const AppConfig({
this.theme = const .new(),
this.cleanup = const .new(),
this.map = const .new(),
this.timeline = const .new(),
this.image = const .new(),
this.viewer = const .new(),
});
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,
cleanup: cleanup ?? this.cleanup,
map: map ?? this.map,
timeline: timeline ?? this.timeline,
image: image ?? this.image,
viewer: viewer ?? this.viewer,
);
@override
@@ -30,11 +45,14 @@ class AppConfig {
other.theme == theme &&
other.cleanup == cleanup &&
other.map == map &&
other.timeline == timeline);
other.timeline == timeline &&
other.image == image &&
other.viewer == viewer);
@override
int get hashCode => Object.hash(theme, cleanup, map, timeline);
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer);
@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),
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
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>(
@@ -1,10 +1,6 @@
import 'package:immich_mobile/domain/models/store.model.dart';
enum Setting<T> {
loadOriginal<bool>(StoreKey.loadOriginal, false),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
autoPlayVideo<bool>(StoreKey.autoPlayVideo, true),
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
enableBackup<bool>(StoreKey.enableBackup, false);
+6 -21
View File
@@ -4,29 +4,15 @@ import 'package:immich_mobile/domain/models/user.model.dart';
/// Defines the data type for each value
enum StoreKey<T> {
version<int>._(0),
assetETag<String>._(1),
currentUser<UserDto>._(2),
deviceIdHash<int>._(3),
deviceId<String>._(4),
backupFailedSince<DateTime>._(5),
backupRequireWifi<bool>._(6),
backupRequireCharging<bool>._(7),
backupTriggerDelay<int>._(8),
serverUrl<String>._(10),
accessToken<String>._(11),
serverEndpoint<String>._(12),
autoBackup<bool>._(13),
backgroundBackup<bool>._(14),
sslClientCertData<String>._(15),
sslClientPasswd<String>._(16),
uploadErrorNotificationGracePeriod<int>._(106),
thumbnailCacheSize<int>._(110),
imageCacheSize<int>._(111),
albumThumbnailCacheSize<int>._(112),
selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114),
preferRemoteImage<bool>._(116),
selfSignedCert<bool>._(120),
selectedAlbumSortReverse<bool>._(123),
enableHapticFeedback<bool>._(126),
customHeaders<String>._(127),
@@ -42,13 +28,6 @@ enum StoreKey<T> {
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
albumGridView<bool>._(140),
loadOriginal<bool>._(101),
// Image viewer navigation settings
loopVideo<bool>._(117),
loadOriginalVideo<bool>._(136),
autoPlayVideo<bool>._(139),
tapToNavigate<bool>._(141),
// Experimental stuff
enableBackup<bool>._(1003),
@@ -57,6 +36,12 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
// 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),
legacyDynamicTheme<bool>._(129),
legacyColorfulInterface<bool>._(130),
@@ -74,5 +74,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
localId: localId,
stackId: stackId,
isEdited: isEdited,
deletedAt: deletedAt,
);
}
@@ -132,6 +132,13 @@ extension<T extends Object> on MetadataDomain<T> {
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
storageIndicator: repo._read(.timelineStorageIndicator),
),
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
viewer: .new(
loopVideo: repo._read(.viewerLoopVideo),
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
tapToNavigate: repo._read(.viewerTapToNavigate),
),
);
case .systemConfig:
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 iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (iconOnly) {
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
@@ -46,17 +47,18 @@ class BaseActionButton extends ConsumerWidget {
}
if (menuItem) {
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
final iconColor = this.iconColor;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
leadingIcon: Icon(iconData, color: iconColor),
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(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(
@@ -18,8 +18,15 @@ class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
final bool useShortLabel;
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
const DeletePermanentActionButton({
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
this.useShortLabel = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -64,7 +71,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
return BaseActionButton(
maxWidth: 110.0,
iconData: Icons.delete_forever,
label: "delete_permanently".t(context: context),
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
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/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -231,7 +230,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
return;
}
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
final tapToNavigate = ref.read(metadataProvider).appConfig.viewer.tapToNavigate;
if (!tapToNavigate) {
_viewer.toggleControls();
return;
@@ -2,15 +2,19 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -33,23 +37,31 @@ class ViewerBottomBar extends ConsumerWidget {
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final originalTheme = context.themeData;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
];
@@ -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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/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/app_settings.service.dart';
import 'package:logging/logging.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 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 videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
@@ -165,7 +161,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return;
}
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
final autoPlayVideo = ref.read(metadataProvider).appConfig.viewer.autoPlayVideo;
if (autoPlayVideo || widget.asset.isMotionPhoto) {
await _notifier.play();
}
@@ -216,7 +212,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
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.setVolume(1);
}
@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
@@ -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/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@@ -95,16 +98,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
),
SafeArea(
bottom: false,
child: SizedBox.square(
child: SizedBox(
height: preferredSize.height,
child: Theme(
data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)),
child: Row(
children: [
const _AppBarBackButton(),
const Spacer(),
if (!showingDetails && !isReadonlyModeEnabled)
if (isInLockedView) ...lockedViewActions else ...actions,
],
child: NavigationToolbar(
centerMiddle: true,
leading: const _AppBarBackButton(),
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
trailing: !showingDetails && !isReadonlyModeEnabled
? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
: null,
),
),
),
@@ -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:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -189,4 +188,6 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
}
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/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@@ -105,7 +104,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return;
}
final loadOriginal = Store.get(StoreKey.loadOriginal, false);
final loadOriginal = MetadataRepository.instance.appConfig.image.loadOriginal;
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest(
localId: key.id,
@@ -1,9 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@@ -123,7 +122,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
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);
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/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -144,7 +143,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
// Due to the flow of the code, this will always happen on first login
user = serverUser;
await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
}
} on ApiException catch (error, stackTrace) {
if (error.code == 401) {
+43 -18
View File
@@ -15,37 +15,62 @@ class AuthGuard extends AutoRouteGuard {
final ApiService _apiService;
final AuthService _authService;
final _log = Logger("AuthGuard");
bool _validateInFlight = false;
AuthGuard(this._apiService, this._authService);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true);
void onNavigation(NavigationResolver resolver, StackRouter router) {
// Synchronously check for the access token. auto_route awaits async
// guards, so we keep this function fully sync and validate the token in
// the background — otherwise a slow validateAccessToken() request would
// block the route transition for as long as the OS-level HTTP timeout.
try {
// Look in the store for an access token
Store.get(StoreKey.accessToken);
// Validate the access token with the server
final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) {
// 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 (_) {
// If there is no access token, take us to the login page
_log.warning('No access token in the store.');
resolver.next(false);
unawaited(router.replaceAll([const LoginRoute()]));
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 an unauthorized request, take us to the login page
if (e.code == HttpStatus.unauthorized) {
_log.warning("Unauthorized access token.");
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
if (e.code != HttpStatus.unauthorized) {
return;
}
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
_log.warning("Unauthorized access token.");
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
} catch (e) {
// Otherwise, this is not fatal, but we still log the warning
_log.warning('Error validating access token from server: $e');
} finally {
_validateInFlight = false;
}
}
}
@@ -2,24 +2,9 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
uploadErrorNotificationGracePeriod<int>(
StoreKey.uploadErrorNotificationGracePeriod,
"uploadErrorNotificationGracePeriod",
2,
),
thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000),
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200),
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
tapToNavigate<bool>(StoreKey.tapToNavigate, "tapToNavigate", false),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
-1
View File
@@ -123,7 +123,6 @@ class AuthService {
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint),
@@ -394,9 +394,13 @@ class BackgroundUploadService {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final fieldsMap = {
'filename': originalFileName ?? filename,
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': deviceAssetId ?? '',
'deviceId': deviceId,
'fileCreatedAt': createdAt.toUtc().toIso8601String(),
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false',
@@ -5,6 +5,8 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -319,8 +321,12 @@ class ForegroundUploadService {
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId);
final fields = {
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': asset.localId!,
'deviceId': deviceId,
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(),
@@ -426,6 +432,9 @@ class ForegroundUploadService {
final filename = p.basename(file.path);
final fields = {
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': deviceAssetId,
'deviceId': Store.get(StoreKey.deviceId),
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false',
+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/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
@@ -81,6 +82,7 @@ enum ActionButtonType {
moveToLockFolder,
removeFromLockFolder,
removeFromAlbum,
restoreTrash,
trash,
deleteLocal,
deletePermanent,
@@ -112,12 +114,17 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
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 =>
context.isOwner && //
context.asset.hasRemote && //
!context.isTrashEnabled ||
context.isInLockedView,
context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
@@ -201,6 +208,11 @@ enum ActionButtonType {
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => RestoreActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
@@ -292,6 +304,7 @@ enum ActionButtonType {
ActionButtonType.moveToLockFolder => 10,
ActionButtonType.deleteLocal => 10,
ActionButtonType.delete => 10,
ActionButtonType.restoreTrash => 10,
// 90: advancedInfo
ActionButtonType.advancedInfo => 90,
// 1: others
@@ -309,6 +322,8 @@ class ActionButtonBuilder {
ActionButtonType.delete,
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
};
static List<Widget> build(ActionButtonContext context) {
+8
View File
@@ -88,6 +88,14 @@ Future<void> _migrateTo26(Drift drift) async {
GroupAssetsBy.values,
);
await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator);
// Image
await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, MetadataKey.imagePreferRemote);
await migrator.migrateBool(StoreKey.legacyLoadOriginal, MetadataKey.imageLoadOriginal);
// Viewer
await migrator.migrateBool(StoreKey.legacyLoopVideo, MetadataKey.viewerLoopVideo);
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo);
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo);
await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate);
await migrator.complete();
}
@@ -32,7 +32,11 @@ class AdvancedSettings extends HookConsumerWidget {
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
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 logLevel = Level.LEVELS[levelId.value].name;
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@@ -12,7 +12,10 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
@override
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(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1,18 +1,20 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/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/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
const ImageViewerTapToNavigateSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tapToNavigate = 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(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -22,7 +24,6 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
valueNotifier: tapToNavigate,
title: "setting_image_navigation_enable_title".tr(),
subtitle: "setting_image_navigation_enable_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
@@ -1,20 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class VideoViewerSettings extends HookConsumerWidget {
const VideoViewerSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final useLoopVideo = useAppSettingsState(AppSettingsEnum.loopVideo);
final useOriginalVideo = useAppSettingsState(AppSettingsEnum.loadOriginalVideo);
final useAutoPlayVideo = useAppSettingsState(AppSettingsEnum.autoPlayVideo);
final viewer = ref.read(appConfigProvider).viewer;
final useAutoPlayVideo = useState(viewer.autoPlayVideo);
final useLoopVideo = useState(viewer.loopVideo);
final useOriginalVideo = useState(viewer.loadOriginalVideo);
useValueChanged<bool, void>(useAutoPlayVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value);
});
useValueChanged<bool, void>(useLoopVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerLoopVideo, useLoopVideo.value);
});
useValueChanged<bool, void>(useOriginalVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -27,19 +37,16 @@ class VideoViewerSettings extends HookConsumerWidget {
valueNotifier: useAutoPlayVideo,
title: "setting_video_viewer_auto_play_title".t(context: context),
subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: useLoopVideo,
title: "setting_video_viewer_looping_title".t(context: context),
subtitle: "loop_videos_description".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: useOriginalVideo,
title: "setting_video_viewer_original_video_title".t(context: context),
subtitle: "setting_video_viewer_original_video_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
@@ -3,10 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -16,9 +13,6 @@ class NotificationSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final permissionService = ref.watch(notificationPermissionProvider);
final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod);
final hasPermission = permissionService == PermissionStatus.granted;
openAppNotificationSettings(BuildContext ctx) {
@@ -41,8 +35,6 @@ class NotificationSetting extends HookConsumerWidget {
);
}
final String formattedValue = _formatSliderValue(sliderValue.value.toDouble());
final notificationSettings = [
if (!hasPermission)
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);
}
}
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();
}
}
@@ -9,9 +9,8 @@ import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
const _kAccessToken = '#ThisIsAToken';
const _kBackgroundBackup = false;
const _kEnableBackup = false;
const _kVersion = 2;
final _kBackupFailedSince = DateTime.utc(2023);
void main() {
late StoreService sut;
@@ -24,15 +23,13 @@ void main() {
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
registerFallbackValue(StoreKey.accessToken);
registerFallbackValue(StoreKey.backupTriggerDelay);
registerFallbackValue(StoreKey.backgroundBackup);
registerFallbackValue(StoreKey.backupFailedSince);
registerFallbackValue(StoreKey.enableBackup);
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
(_) async => [
const StoreDto(StoreKey.accessToken, _kAccessToken),
const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup),
const StoreDto(StoreKey.enableBackup, _kEnableBackup),
const StoreDto(StoreKey.version, _kVersion),
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
],
);
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
@@ -49,9 +46,8 @@ void main() {
test('Populates the internal cache on init', () {
verify(() => mockDriftStoreRepo.getAll()).called(1);
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.backupFailedSince), _kBackupFailedSince);
// Other keys should be null
expect(sut.tryGet(StoreKey.currentUser), isNull);
});
@@ -151,9 +147,8 @@ void main() {
await sut.clear();
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
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.backupFailedSince), isNull);
});
});
}
@@ -12,9 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import '../../fixtures/user.stub.dart';
const _kTestAccessToken = "#TestToken";
final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45);
const _kTestVersion = 10;
const _kTestBackupRequireWifi = false;
const _kTestBackupRequireCharging = false;
final _kTestUser = UserStub.admin;
Future<void> _populateStore(Drift db) async {
@@ -22,16 +21,8 @@ Future<void> _populateStore(Drift db) async {
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupRequireWifi.id),
intValue: const Value(_kTestBackupRequireWifi ? 1 : 0),
stringValue: const Value(null),
),
);
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupFailedSince.id),
intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch),
id: Value(StoreKey.backupRequireCharging.id),
intValue: const Value(_kTestBackupRequireCharging ? 1 : 0),
stringValue: const Value(null),
),
);
@@ -84,20 +75,12 @@ void main() {
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 {
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isNull);
await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi);
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, _kTestBackupRequireWifi);
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isNull);
await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging);
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, _kTestBackupRequireCharging);
});
test('converts user', () async {
@@ -115,11 +98,11 @@ void main() {
});
test('delete()', () async {
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isFalse);
await sut.delete(StoreKey.backupRequireWifi);
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isNull);
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isFalse);
await sut.delete(StoreKey.backupRequireCharging);
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isNull);
});
test('deleteAll()', () async {
@@ -164,14 +147,12 @@ void main() {
emitsInOrder([
[
const StoreDto<Object>(StoreKey.version, _kTestVersion),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
],
[
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
],
]),
@@ -79,7 +79,6 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.system);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
});
@@ -90,7 +89,6 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
@@ -135,5 +133,4 @@ void main() {
await expectation;
});
});
}
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
LocalAsset createLocalAsset({
@@ -37,6 +38,7 @@ RemoteAsset createRemoteAsset({
DateTime? updatedAt,
DateTime? uploadedAt,
bool isFavorite = false,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: 'remote-id',
@@ -50,6 +52,7 @@ RemoteAsset createRemoteAsset({
uploadedAt: uploadedAt ?? DateTime.now(),
isFavorite: isFavorite,
isEdited: false,
deletedAt: deletedAt,
);
}
@@ -458,6 +461,62 @@ void main() {
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', () {
@@ -494,6 +553,24 @@ void main() {
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', () {
+90 -145
View File
@@ -6,7 +6,7 @@ settings:
injectWorkspacePackages: true
overrides:
canvas: 2.11.2
canvas: 3.2.3
sharp: ^0.34.5
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))
vitest:
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:
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)
vitest:
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:
specifier: ^0.4.0
version: 0.4.5(vitest@4.1.5)
@@ -663,7 +663,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
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:
specifier: ^10.0.0
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))
vitest:
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:
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)
vitest:
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:
@@ -6182,9 +6182,9 @@ packages:
caniuse-lite@1.0.30001790:
resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==}
canvas@2.11.2:
resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==}
engines: {node: '>=6'}
canvas@3.2.3:
resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==}
engines: {node: ^18.12.0 || >= 20.9.0}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -6964,10 +6964,6 @@ packages:
decode-named-character-reference@1.2.0:
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:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@@ -7567,6 +7563,10 @@ packages:
resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==}
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:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -7866,6 +7866,9 @@ packages:
get-tsconfig@4.13.0:
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:
resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==}
@@ -8591,7 +8594,7 @@ packages:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'}
peerDependencies:
canvas: 2.11.2
canvas: 3.2.3
peerDependenciesMeta:
canvas:
optional: true
@@ -9308,10 +9311,6 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
mimic-response@2.1.0:
resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==}
engines: {node: '>=8'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
@@ -9479,6 +9478,9 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
@@ -9552,6 +9554,10 @@ packages:
no-case@3.0.4:
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:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -10499,6 +10505,12 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
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:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -11176,8 +11188,8 @@ packages:
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@3.1.1:
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-icons@16.17.0:
resolution: {integrity: sha512-bRrGtzM6NLgxeMWmRcfDdrRksECk101lRrCn6jjj6qzUB6lQ+E5smnr52rqS1kLPmbLpS/g6iF463j50M4BT7A==}
@@ -11886,6 +11898,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
@@ -15799,22 +15814,6 @@ snapshots:
'@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)':
dependencies:
detect-libc: 2.1.2
@@ -17248,7 +17247,7 @@ snapshots:
svelte: 5.55.2
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)
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)':
dependencies:
@@ -17964,7 +17963,7 @@ snapshots:
'@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:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -17979,7 +17978,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
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:
- supports-color
@@ -17995,7 +17994,7 @@ snapshots:
obug: 2.1.1
std-env: 4.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':
dependencies:
@@ -18762,24 +18761,10 @@ snapshots:
caniuse-lite@1.0.30001790: {}
canvas@2.11.2:
canvas@3.2.3:
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
nan: 2.26.2
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
node-addon-api: 7.1.1
prebuild-install: 7.1.3
optional: true
ccount@2.0.1: {}
@@ -19582,11 +19567,6 @@ snapshots:
dependencies:
character-entities: 2.0.2
decompress-response@4.2.1:
dependencies:
mimic-response: 2.1.0
optional: true
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@@ -20331,6 +20311,9 @@ snapshots:
optionalDependencies:
exiftool-vendored.exe: 13.58.0
expand-template@2.0.3:
optional: true
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
@@ -20416,11 +20399,10 @@ snapshots:
fabric@7.3.1:
optionalDependencies:
canvas: 2.11.2
jsdom: 26.1.0(canvas@2.11.2)
canvas: 3.2.3
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
- bufferutil
- encoding
- supports-color
- utf-8-validate
@@ -20712,6 +20694,9 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0:
optional: true
github-slugger@1.5.0: {}
gl-matrix@3.4.4: {}
@@ -21533,7 +21518,7 @@ snapshots:
dependencies:
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:
cssstyle: 4.6.0
data-urls: 5.0.0
@@ -21556,37 +21541,7 @@ snapshots:
ws: 8.20.0
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 2.11.2(encoding@0.1.13)
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
canvas: 3.2.3
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -22589,9 +22544,6 @@ snapshots:
mimic-function@5.0.1: {}
mimic-response@2.1.0:
optional: true
mimic-response@3.1.0: {}
mimic-response@4.0.0: {}
@@ -22745,6 +22697,9 @@ snapshots:
nanoid@5.1.9: {}
napi-build-utils@2.0.0:
optional: true
natural-compare-lite@1.4.0: {}
natural-compare@1.4.0: {}
@@ -22817,6 +22772,11 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
node-abi@3.92.0:
dependencies:
semver: 7.7.4
optional: true
node-abort-controller@3.1.1: {}
node-addon-api@4.3.0: {}
@@ -22837,11 +22797,6 @@ snapshots:
emojilib: 2.4.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):
dependencies:
whatwg-url: 5.0.0
@@ -23808,6 +23763,22 @@ snapshots:
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: {}
prettier-linter-helpers@1.0.1:
@@ -24731,9 +24702,9 @@ snapshots:
simple-concat@1.0.1:
optional: true
simple-get@3.1.1:
simple-get@4.0.1:
dependencies:
decompress-response: 4.2.1
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
optional: true
@@ -25575,6 +25546,11 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
optional: true
tweetnacl@0.14.5: {}
type-check@0.4.0:
@@ -25984,9 +25960,9 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@4.1.5):
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:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -26015,7 +25991,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 24.12.2
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2)
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
- jiti
- less
@@ -26030,7 +26006,7 @@ snapshots:
- tsx
- 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:
'@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))
@@ -26057,42 +26033,11 @@ snapshots:
'@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(encoding@0.1.13))
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
- 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)):
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)):
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@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
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2)
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
- msw
+1 -1
View File
@@ -28,7 +28,7 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide'
- bcrypt
overrides:
canvas: 2.11.2
canvas: 3.2.3
sharp: ^0.34.5
# pending docusaurus 3.10.1
webpackbar: ^7.0.0
@@ -132,7 +132,7 @@ export class MachineLearningRepository {
private async check(url: string) {
let healthy = false;
try {
const response = await fetch(new URL('/ping', url), {
const response = await fetch(new URL('ping', url), {
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
});
if (response.ok) {
@@ -170,7 +170,7 @@ export class MachineLearningRepository {
...this.config.urls.filter((url) => !this.isHealthy(url)),
]) {
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) {
this.setHealthy(url, true);
return response.json();
@@ -1,5 +1,5 @@
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 { LoggingRepository } from 'src/repositories/logging.repository';
import { mimeTypes } from 'src/utils/mime-types';
@@ -89,7 +89,7 @@ export class MetadataRepository {
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
geolocation: true,
// 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'],
taskTimeoutMillis: 2 * 60 * 1000,
});
@@ -107,8 +107,8 @@ export class MetadataRepository {
}
readTags(path: string): Promise<ImmichTags> {
const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
return this.exiftool.read(path, { readArgs: args }).catch((error) => {
const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined;
return this.exiftool.read(path, options).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
return {};
}) as Promise<ImmichTags>;
@@ -212,16 +212,10 @@
};
}
try {
return {
fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
};
} catch {
$mapSettings.dateAfter = '';
$mapSettings.dateBefore = '';
return {};
}
return {
fileCreatedAfter: dateAfter?.toUTC().toISO(),
fileCreatedBefore: dateBefore?.toUTC().toISO(),
};
}
async function loadMapMarkers() {
@@ -237,7 +231,7 @@
{
isArchived: includeArchived || undefined,
isFavorite: onlyFavorites || undefined,
fileCreatedAfter: fileCreatedAfter || undefined,
fileCreatedAfter,
fileCreatedBefore,
withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined,
+27 -38
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
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 { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -41,29 +40,21 @@
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="shrink-0 text-sm immich-form-label" for="date-after">{$t('date_after')}</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
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">
<Field label={$t('date_after')}>
<DatePicker bind:value={settings.dateAfter} maxDate={settings.dateBefore} />
</Field>
<Field label={$t('date_before')}>
<DatePicker bind:value={settings.dateBefore} />
</Field>
<div class="flex justify-center">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
settings.dateAfter = undefined;
settings.dateBefore = undefined;
}}
>
{$t('remove_custom_date_range')}
@@ -71,7 +62,7 @@
</div>
</div>
{: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')}>
<Select
bind:value={settings.relativeDate}
@@ -82,40 +73,38 @@
},
{
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 } }),
value: Duration.fromObject({ days: 7 }).toISO() || '',
value: Duration.fromObject({ days: 7 }).toISO(),
},
{
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 } }),
value: Duration.fromObject({ years: 1 }).toISO() || '',
value: Duration.fromObject({ years: 1 }).toISO(),
},
{
label: $t('past_durations.years', { values: { years: 3 } }),
value: Duration.fromObject({ years: 3 }).toISO() || '',
value: Duration.fromObject({ years: 3 }).toISO(),
},
]}
/>
</Field>
<div class="text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
{/if}
</Stack>
@@ -1,9 +1,9 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import { handleUpdatePersonBirthDate } from '$lib/services/person.service';
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 { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
@@ -12,32 +12,25 @@
};
let { person, onClose }: Props = $props();
let birthDate = $derived(person.birthDate ?? '');
let birthDate = $derived(person.birthDate ? DateTime.fromISO(person.birthDate) : undefined);
const onSubmit = async () => {
const success = await handleUpdatePersonBirthDate(person, birthDate);
const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? '');
if (success) {
onClose();
}
};
const todayFormatted = new Date().toISOString().split('T')[0];
</script>
<FormModal title={$t('set_date_of_birth')} size="small" icon={mdiCake} {onClose} {onSubmit}>
<Text size="small">{$t('birthdate_set_description')}</Text>
<div class="my-4 flex flex-col gap-2">
<DateInput
class="immich-form-input"
id="birthDate"
name="birthDate"
type="date"
bind:value={birthDate}
max={todayFormatted}
/>
<div class="my-2 flex flex-col gap-2">
<Field label={$t('date_of_birth')}>
<DatePicker bind:value={birthDate} maxDate={DateTime.now()} />
<HelperText>{$t('birthdate_set_description')}</HelperText>
</Field>
{#if person.birthDate}
<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')}
</Button>
</div>
+3 -4
View File
@@ -1,3 +1,4 @@
import type { DateTime } from 'luxon';
import { persisted } from 'svelte-persisted-store';
import { browser } from '$app/environment';
import { defaultLang } from '$lib/constants';
@@ -26,8 +27,8 @@ export interface MapSettings {
withPartners: boolean;
withSharedAlbums: boolean;
relativeDate: string;
dateAfter: string;
dateBefore: string;
dateAfter?: DateTime<true>;
dateBefore?: DateTime<true>;
}
const defaultMapSettings = {
@@ -37,8 +38,6 @@ const defaultMapSettings = {
withPartners: false,
withSharedAlbums: false,
relativeDate: '',
dateAfter: '',
dateBefore: '',
};
const persistedObject = <T>(key: string, defaults: T) =>
+33 -4
View File
@@ -4,15 +4,18 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.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 { 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 { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util';
import Portal from '$lib/elements/Portal.svelte';
interface Props {
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>
<OnEvents {onPersonThumbnailReady} />
@@ -122,15 +134,20 @@
draggable="false">{$t('view_all')}</a
>
</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)}
<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
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover"
/>
</a>
</button>
{/each}
</div>
</div>
@@ -140,3 +157,15 @@
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if}
</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}