Compare commits

...

38 Commits

Author SHA1 Message Date
Jason Rasmussen
1f0c662965 refactor(mobile): actions 2026-07-22 13:25:05 -04:00
Jason Rasmussen
b9f6c4aaf2 feat: password invalidate sessions (#30125) 2026-07-22 12:09:05 -04:00
shenlong
23778551f7 fix: locked view and asset view provider (#30136)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-22 09:51:33 -05:00
bo0tzz
457da17c5d fix: always set extension from provided file (#29839)
* fix: always set extension from provided file

The livephoto case can also happen with other files; for example, we
receive a DNG file as JPEG but then still call it .dng

Fixes #29805, partially addresses #28602

* chore: test cases for file extension naming
2026-07-22 18:29:54 +05:30
Jan Bader
12f741d911 chore(cli): remove -h shorthand for --skip-hash (#30119)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-22 13:25:29 +02:00
Aditya Raj Singh
a0c7b81142 fix(server): dissolve stack when its non-primary assets are deleted (#29354) 2026-07-22 13:00:52 +02:00
Daniel Dietzler
20997823d8 chore: mise monorepo support is now stable (#30131) 2026-07-22 12:55:47 +02:00
okxint
8a875f5978 fix(web): use correct date field for shift-click range in Recently Added (#30071) 2026-07-22 11:53:45 +02:00
renovate[bot]
8435d219e4 chore(deps): update dependency opentofu to v1.12.4 (#28491) 2026-07-22 09:30:08 +00:00
Ben Beckford
8bbc252703 feat: exif metadata workflow filter (#29295) 2026-07-22 11:20:57 +02:00
renovate[bot]
2d3cf8ffaa chore(deps): update dependency terragrunt to v1.1.1 (#29399) 2026-07-22 11:16:09 +02:00
renovate[bot]
33cba2f9af chore(deps): update grafana/grafana docker tag to v12.4.5-ubuntu (#29670) 2026-07-22 10:59:29 +02:00
renovate[bot]
75053ebfba chore(deps): update terraform cloudflare to v4.52.8 (#29671) 2026-07-22 10:59:04 +02:00
Yaros
ba9ec7c80e fix: backup delay translation key parsing (#30112) 2026-07-22 10:58:38 +02:00
Yaros
99370b2ac4 fix: admin user details responsive layout (#30114) 2026-07-22 10:54:26 +02:00
Ben Beckford
0f5bf6071f chore(server): test assetDateFilter workflow method (#30110) 2026-07-22 10:48:31 +02:00
Ijas
998fd5f6a8 fix(mobile): send date-only value for memories query param (#30049)
* fix(ios-widget): send date-only value for memories query param

Server v3.0.3 (immich-app#29907) validates the /memories `for` query
param with a date-only schema (YYYY-MM-DD). The iOS widget still sent a
full ISO 8601 datetime, so every request 400s and the widget shows
"Unable to connect to your Immich instance" on any server >= v3.0.3.

Build the value from the local calendar day, matching what the web
client (memory-manager.svelte.ts) and Android already send, instead of
a UTC datetime.

Also fix a related bug in the timeline provider: fetchMemory was
called with a bare try inside a Task, so a failure silently dropped
the error and never called completion, leaving the widget in a stale
state instead of showing an error card. Wrap it in a guard/try?, same
pattern already used in getSnapshot.

Fixes immich-app#30044

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>

* address review: drop unrelated widget change and comment

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>

---------

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
2026-07-22 14:19:19 +06:00
renovate[bot]
f413c446e3 chore(deps): update github-actions (#30089) 2026-07-22 09:15:38 +02:00
Yaros
c9539a4aa8 fix(mobile): map unresponsive after viewing asset (#27036)
* fix(mobile): map unresponsive after viewing asset

* chore: better fix
2026-07-22 02:56:23 +05:30
Imperator Ruscal
a560e08693 fix: re-evaluate OIDC role claim on every login and support array values (#29991)
* fix(server): re-evaluate OIDC role claim on every login and support array values

Previously the OIDC role claim (immich_role) was only read at user
auto-registration time and only accepted as a scalar string, so admin
status never updated after the first login and array-valued role/group
claims (common with Keycloak, Entra ID, etc.) were silently ignored.

Now the role claim is normalized from either a string or an array of
strings, and existing users have their isAdmin flag synced from the
claim on every login, keeping the IdP as the source of truth for
privileges while leaving isAdmin untouched when the claim is blank.

* fix(server): use .includes() instead of .some() for role claim check

Satisfies unicorn/prefer-includes lint rule flagged by CI.

* fix(server): resolve missing role claim to standard user and fix test mock

Default the OIDC role claim to 'user' when the IdP omits it so
auto-registration doesn't crash, and add the missing getAdmin mock in the
auth.service test so it correctly simulates an existing admin rather than
the first-user-registration path.

* fix: claim sync logic

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2026-07-21 17:21:19 -04:00
Yaros
49c0baf076 fix: disable slideshow crossfade on reduced motion (#29826)
* fix: disable slideshow crossfade on reduced motion

* chore: share photoview instances

* chore: listen to changes
2026-07-22 01:59:26 +05:30
shenlong
3485dbffda refactor(mobile): extract CachedKeyValueRepository (#28789)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-21 20:23:50 +00:00
Yaros
e508781125 fix: limit ocr overlay to images (#30116) 2026-07-21 20:23:04 +00:00
Peter Ombodi
a30ebab70c fix(mobile): refresh asset stack after deleting viewer item (#28164)
* fix(mobile): refresh asset stack after deleting viewer item

* fix(mobile): clamp stack index,
filter visible stack assets

* refactor(mobile): simplify stack asset refresh handling

* fix(mobile): revert changes in DeleteLocalActionButton

* style(mobile): reformat code

* fix(mobile): remove redundant setStack

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-07-22 01:48:47 +05:30
Daniel Dietzler
5d7283e44d fix: album update event emitting (#30120) 2026-07-21 19:43:27 +00:00
Lorenzo Rota
73329a8ce8 fix(server): send id_token_hint on OIDC logout (#29720)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-21 19:19:13 +02:00
Santo Shakil
d5adfb97dd fix(mobile): treat wired ethernet as unmetered on ios (#29351)
check the metered flags instead of the interface type, so ethernet counts like android already does.
2026-07-21 11:02:36 -05:00
Priyanshu
b70cb58bc8 fix(web): improve slideshow controls visibility on bright backgrounds (#29950)
Co-authored-by: priyanshuANDcoad <priyanshu23154050@akgec.ac.in>
2026-07-21 15:58:51 +00:00
Santo Shakil
943bfafb7a fix(mobile): show real error when an asset can't be added to an album (#29754)
* fix(mobile): show real error when an asset can't be added to an album

* fix(mobile): show added toast when an add-to-album partly succeeds

* docs(mobile): explain why a partial add-to-album shows added
2026-07-21 10:56:50 -05:00
Timon
ee4bd3f833 feat: add album asset event handling (#29008)
* feat: add album asset event handling

* fix medium tests

* patch medium test

* collapse in AlbumUpdate
2026-07-21 10:55:46 -05:00
Adam Gastineau
7a7303aceb fix(mobile): properly group download tasks for Live Photos (#29952)
* fix(mobile): properly group download tasks for Live Photos

* Remove completely unused onLivePhotoDownloadStatus

* Remove dead code
2026-07-21 10:45:15 -05:00
Adam Gastineau
0a7c067b20 chore(mobile): bump maplibre to 0.26.2 in SwiftPM lock (#30108) 2026-07-21 10:44:30 -05:00
Luis Nachtigall
c8de56c422 fix(mobile): prevent duplicate album creation during submission (#30003)
* fix: prevent duplicate album creation during submission

* Nit fix

---------

Co-authored-by: Adam Gastineau <adam@agg.im>
2026-07-21 15:31:23 +00:00
shenlong
0e4572c37b chore: pin photo_manager to 3.9.0 (#30091)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-21 10:06:27 -05:00
Santo Shakil
14a2776453 fix(mobile): add album picker to the partner view bottom sheet (#30099) 2026-07-21 10:05:34 -05:00
renovate[bot]
59bc81423c chore(deps): update github-actions (major) (#30095) 2026-07-21 16:37:38 +02:00
Roberto Carlos Martinez Arriaga
e6fff3b15e feat(web): add maintenance link to command palette (#30016) 2026-07-21 16:27:23 +02:00
renovate[bot]
755df23b22 chore(deps): update ghcr.io/devcontainers/features/docker-in-docker docker tag to v4 (#30094) 2026-07-21 16:05:54 +02:00
121 changed files with 1942 additions and 1187 deletions

View File

@@ -83,7 +83,7 @@
}
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"ghcr.io/devcontainers/features/docker-in-docker:4": {
// https://github.com/devcontainers/features/issues/1466
"moby": false
}

View File

@@ -15,7 +15,7 @@ jobs:
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false

View File

@@ -87,7 +87,7 @@ jobs:
permission-contents: read
permission-pull-requests: write
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -106,14 +106,14 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'zulu'
java-version: '17'
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
@@ -184,7 +184,7 @@ jobs:
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -201,7 +201,7 @@ jobs:
contents: read
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: macos-15
runs-on: macos-26
steps:
- id: token
@@ -215,7 +215,7 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@@ -241,7 +241,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: '3.3'
bundler-cache: true

View File

@@ -27,7 +27,7 @@ jobs:
permission-actions: write
- name: Check out code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -20,12 +20,12 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
@@ -38,7 +38,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@@ -38,7 +38,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -71,7 +71,7 @@ jobs:
permission-contents: read
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

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

View File

@@ -51,14 +51,14 @@ jobs:
permission-contents: read
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -71,7 +71,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
category: '/language:${{matrix.language}}'

View File

@@ -62,7 +62,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -137,7 +137,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -25,7 +25,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -14,7 +14,7 @@ jobs:
contents: read
steps:
- name: Checkout pubspec for versionCode
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false

View File

@@ -23,7 +23,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: true

View File

@@ -61,7 +61,7 @@ jobs:
permission-contents: write
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ steps.token.outputs.token }}
persist-credentials: true
@@ -140,7 +140,7 @@ jobs:
permission-actions: read
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
@@ -152,7 +152,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
draft: true
prerelease: ${{ needs.bump_version.outputs.rc }}

View File

@@ -23,7 +23,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -58,7 +58,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -85,7 +85,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -114,7 +114,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -146,7 +146,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -178,7 +178,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -223,7 +223,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -262,7 +262,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -291,7 +291,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -342,7 +342,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -375,7 +375,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: 'recursive'
@@ -412,7 +412,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: 'recursive'
@@ -422,7 +422,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -490,7 +490,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: 'recursive'
@@ -500,7 +500,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -598,7 +598,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -637,7 +637,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -669,7 +669,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -699,7 +699,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -722,7 +722,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -781,7 +781,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -1,6 +1,6 @@
[tools]
terragrunt = "1.0.3"
opentofu = "1.11.6"
terragrunt = "1.1.1"
opentofu = "1.12.4"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"

View File

@@ -2,37 +2,35 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.7"
constraints = "4.52.7"
version = "4.52.8"
constraints = "4.52.8"
hashes = [
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.7"
version = "4.52.8"
}
}
}

View File

@@ -2,37 +2,35 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.7"
constraints = "4.52.7"
version = "4.52.8"
constraints = "4.52.8"
hashes = [
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.7"
version = "4.52.8"
}
}
}

View File

@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
image: grafana/grafana:12.4.5-ubuntu@sha256:00396460e499415c828b7c298f19287c8a0f95e72412ee37ac11723655c2d6b9
volumes:
- grafana-data:/var/lib/grafana

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -35,6 +35,7 @@ Found Admin:
- Email=admin@example.com
- Name=Immich Admin
? Please choose a new password (optional) immich-is-cool
? Invalidate existing sessions? Yes
The admin password has been updated.
```

View File

@@ -650,7 +650,7 @@ describe(`immich upload`, () => {
]);
expect(stdout).toBe('');
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`);
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`);
expect(exitCode).not.toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });

View File

@@ -65,6 +65,10 @@ describe(`immich-admin`, () => {
child.stdout.on('data', (chunk) => {
data += chunk;
if (data.includes('Please choose a new password (optional)')) {
child.stdin.write('\n');
}
if (data.includes('Invalidate existing sessions?')) {
child.stdin.end('\n');
}
});

132
mise.lock
View File

@@ -94,6 +94,23 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
@@ -112,17 +129,12 @@ version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
@@ -133,18 +145,6 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
@@ -248,43 +248,43 @@ version = "7.5.0"
backend = "npm:oazapfts"
[[tools.opentofu]]
version = "1.11.6"
version = "1.12.4"
backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
[tools.opentofu."platforms.linux-x64"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
[tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399"
checksum = "sha256:7c06e4390d9ccd467773e37ff1c3d833c7ca0c24742cd9e9ad47284bea472247"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646544"
[tools.opentofu."platforms.macos-x64"]
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384"
checksum = "sha256:ead1d2ce643addb4ffeb93240b9377ae1c2fd793a6bd22d65922ac37adfdf546"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646688"
[tools.opentofu."platforms.windows-x64"]
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
checksum = "sha256:a4d86a07755c8d151f20f945e6cfb5b40deeed942af36a9bd385c5c2e965d5dd"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646543"
[[tools.pnpm]]
version = "11.13.1"
@@ -327,40 +327,40 @@ url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563981"
provenance = "github-attestations"
[[tools.terragrunt]]
version = "1.0.3"
version = "1.1.1"
backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
[tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
[tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
[tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
[tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628"
[tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520"
[tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421"

View File

@@ -1,4 +1,4 @@
experimental_monorepo_root = true
monorepo_root = true
[monorepo]
config_roots = [
@@ -17,8 +17,8 @@ config_roots = [
[tools]
node = "24.15.0"
pnpm = "11.13.1"
terragrunt = "1.0.3"
opentofu = "1.11.6"
terragrunt = "1.1.1"
opentofu = "1.12.4"
"npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
@@ -35,7 +35,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
[settings]
experimental = true
pin = true
lockfile = true

View File

@@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
"version" : "6.14.0"
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
"version" : "6.27.0"
}
},
{

View File

@@ -43,15 +43,16 @@ class ConnectivityApiImpl: ConnectivityApi {
capabilities.append(.vpn)
}
// Determine if connection is unmetered:
// - Must be on WiFi (not cellular)
// - Must not be expensive (rules out personal hotspot)
// - Must not be constrained (Low Data Mode)
// Note: VPN over cellular should still be considered metered
// Determine if connection is unmetered from the OS metered flags rather than
// the interface type, so wired ethernet (iPhone USB adapters, Apple Silicon
// Macs) is treated as unmetered like Wi-Fi:
// - Not on cellular
// - Not expensive (also rules out cellular and personal hotspot)
// - Not constrained (Low Data Mode)
// Note: VPN over cellular stays metered because the path is still expensive.
let isOnCellular = path.usesInterfaceType(.cellular)
let isOnWifi = path.usesInterfaceType(.wifi)
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
if !isOnCellular && !path.isExpensive && !path.isConstrained {
capabilities.append(.unmetered)
}

View File

@@ -203,7 +203,10 @@ class ImmichAPI {
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
let localDay = date.formatted(
Date.ISO8601FormatStyle(timeZone: .current).year().month().day().dateSeparator(.dash)
)
let memoryParams = [URLQueryItem(name: "for", value: localDay)]
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,

View File

@@ -24,6 +24,12 @@ class ViewerReloadAssetEvent extends Event {
const ViewerReloadAssetEvent();
}
class ViewerStackAssetDeletedEvent extends Event {
final int stackIndex;
const ViewerStackAssetDeletedEvent({required this.stackIndex});
}
// Multi-Select Events
class MultiSelectToggleEvent extends Event {
final bool isEnabled;

View File

@@ -1,17 +1,16 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
enum SettingsKey<T> {
// Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
themeDynamic<bool>(),
themeColorfulInterface<bool>(),
@@ -27,13 +26,13 @@ enum SettingsKey<T> {
// Network
networkAutoEndpointSwitching<bool>(),
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
networkPreferredWifiName<String?>(),
networkLocalEndpoint<String?>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
// Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
albumIsReverse<bool>(),
albumIsGrid<bool>(),
@@ -47,195 +46,45 @@ enum SettingsKey<T> {
// Timeline
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(),
mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(),
// Cleanup
cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(),
cleanupDefaultsInitialized<bool>(),
// Share
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
shareFileType<ShareAssetType>(codec: EnumCodec(ShareAssetType.values)),
// Slideshow
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values)),
// Feature message
featureMessageSeenRelease<SemVer>(codec: _SemVerCodec());
featureMessageSeenRelease<SemVer>(codec: SemVerCodec());
final _SettingsCodec<T>? _codecOverride;
final ValueCodec<T>? _codecOverride;
const SettingsKey({_SettingsCodec<T>? codec}) : _codecOverride = codec;
const SettingsKey({ValueCodec<T>? codec}) : _codecOverride = codec;
_SettingsCodec<T> get _codec => _codecOverride ?? _SettingsCodec.forType(T);
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}
sealed class _SettingsCodec<T> {
const _SettingsCodec();
String encode(T value);
T decode(String raw);
static final Map<Type, _SettingsCodec<Object>> _primitives = {
..._register<int>(_PrimitiveCodec.integer),
..._register<double>(_PrimitiveCodec.real),
..._register<bool>(_PrimitiveCodec.boolean),
..._register<String>(_PrimitiveCodec.string),
..._register<DateTime>(const _DateTimeCodec()),
};
static Map<Type, _SettingsCodec<Object>> _register<T>(_SettingsCodec<Object> codec) => {
T: codec,
// Reifies the nullable type T so it can be used as a key in the _primitives map
_typeOf<T?>(): codec,
};
static Type _typeOf<T>() => T;
static _SettingsCodec<T> forType<T>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.');
}
return codec as _SettingsCodec<T>;
}
}
final class _EnumCodec<T extends Enum> extends _SettingsCodec<T> {
final List<T> values;
const _EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class _DateTimeCodec extends _SettingsCodec<DateTime> {
const _DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class _SemVerCodec extends _SettingsCodec<SemVer> {
const _SemVerCodec();
@override
String encode(SemVer value) => value.toString();
@override
SemVer decode(String raw) => SemVer.fromString(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
final _SettingsCodec<K> _keyCodec;
final _SettingsCodec<V> _valueCodec;
const _MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return {};
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class _ListCodec<T extends Object> extends _SettingsCodec<List<T>> {
final _SettingsCodec<T> _elementCodec;
const _ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class _PrimitiveCodec<T extends Object> extends _SettingsCodec<T> {
final T Function(String) _parse;
const _PrimitiveCodec._(this._parse);
@override
String encode(T value) => value.toString();
@override
T decode(String raw) => _parse(raw);
static const integer = _PrimitiveCodec<int>._(int.parse);
static const real = _PrimitiveCodec<double>._(double.parse);
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
static const string = _PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}

View File

@@ -0,0 +1,153 @@
import 'dart:convert';
import 'package:immich_mobile/utils/semver.dart';
sealed class ValueCodec<T> {
const ValueCodec();
String encode(T value);
T decode(String raw);
static final Map<Type, ValueCodec<Object>> _primitives = {
..._register<int>(PrimitiveCodec.integer),
..._register<double>(PrimitiveCodec.real),
..._register<bool>(PrimitiveCodec.boolean),
..._register<String>(PrimitiveCodec.string),
..._register<DateTime>(const DateTimeCodec()),
};
static Map<Type, ValueCodec<Object>> _register<T>(ValueCodec<Object> codec) => {
T: codec,
// Reifies the nullable type T so it can be used as a key in the _primitives map
_typeOf<T?>(): codec,
};
static Type _typeOf<T>() => T;
static ValueCodec<T> forType<T>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.');
}
return codec as ValueCodec<T>;
}
}
final class EnumCodec<T extends Enum> extends ValueCodec<T> {
final List<T> values;
const EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class DateTimeCodec extends ValueCodec<DateTime> {
const DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class SemVerCodec extends ValueCodec<SemVer> {
const SemVerCodec();
@override
String encode(SemVer value) => value.toString();
@override
SemVer decode(String raw) => SemVer.fromString(raw);
}
final class MapCodec<K extends Object, V extends Object> extends ValueCodec<Map<K, V>> {
final ValueCodec<K> _keyCodec;
final ValueCodec<V> _valueCodec;
const MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
continue;
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
final ValueCodec<T> _elementCodec;
const ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class PrimitiveCodec<T extends Object> extends ValueCodec<T> {
final T Function(String) _parse;
const PrimitiveCodec._(this._parse);
@override
String encode(T value) => value.toString();
@override
T decode(String raw) => _parse(raw);
static const integer = PrimitiveCodec<int>._(int.parse);
static const real = PrimitiveCodec<double>._(double.parse);
static const boolean = PrimitiveCodec<bool>._(bool.parse);
static const string = PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}

View File

@@ -159,8 +159,8 @@ class RemoteAlbumService {
return updatedAlbum;
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
return _repository.getDateRange(albumId);
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
return _repository.watchDateRange(albumId);
}
Future<List<UserDto>> getSharedUsers(String albumId) {
@@ -175,12 +175,12 @@ class RemoteAlbumService {
return _repository.getAssets(albumId);
}
Future<int> addAssets({required String albumId, required List<String> assetIds}) async {
Future<({int added, int failed})> addAssets({required String albumId, required List<String> assetIds}) async {
final album = await _albumApiRepository.addAssets(albumId, assetIds);
await _repository.addAssets(albumId, album.added);
return album.added.length;
return (added: album.added.length, failed: album.failed.length);
}
/// !TODO The name here is not clear as we have addAssets method above,
@@ -196,7 +196,7 @@ class RemoteAlbumService {
}) async {
int addedCount = 0;
if (candidates.remoteAssetIds.isNotEmpty) {
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
addedCount += (await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds)).added;
}
if (candidates.localAssetsToUpload.isNotEmpty) {
addedCount += await _uploadAndAddLocals(

View File

@@ -0,0 +1,41 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
abstract class CachedKeyValueRepository<K extends Enum, S> {
CachedKeyValueRepository(this._snapshot);
S _snapshot;
S get snapshot => _snapshot;
@protected
set snapshot(S value) => _snapshot = value;
List<K> get keys;
Object decodeValue(K key, String raw);
S buildSnapshot(Map<K, Object?> overrides);
Selectable<({String key, String? value})> selectable();
Future<void> refresh() async => _snapshot = _build(await selectable().get());
Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
S _build(List<({String key, String? value})> rows) => buildSnapshot(
rows.fold({}, (overrides, row) {
final key = keys.firstWhereOrNull((key) => key.name == row.key);
if (key == null) {
return overrides;
}
Object? decodedValue;
if (row.value != null) {
decodedValue = decodeValue(key, row.value!);
}
return {...overrides, key: decodedValue};
}),
);
}

View File

@@ -217,7 +217,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
});
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.createdAt.min(), _db.remoteAssetEntity.createdAt.max()])
@@ -229,7 +229,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final minDate = row.read(_db.remoteAssetEntity.createdAt.min());
final maxDate = row.read(_db.remoteAssetEntity.createdAt.max());
return (minDate ?? DateTime.now(), maxDate ?? DateTime.now());
}).getSingle();
}).watchSingle();
}
Future<List<UserDto>> getSharedUsers(String albumId) async {

View File

@@ -71,7 +71,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
final query = _db.remoteAssetEntity.select()
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..where(
(row) =>
row.stackId.equals(stackId) &
row.id.equals(asset.id).not() &
row.deletedAt.isNull() &
row.visibility.equalsValue(AssetVisibility.timeline),
)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get();

View File

@@ -1,13 +1,14 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SettingsRepository extends DriftDatabaseRepository {
class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
final Drift _db;
SettingsRepository._(this._db) : super(_db);
SettingsRepository._(this._db) : super(const .new());
static SettingsRepository? _instance;
@@ -19,9 +20,6 @@ class SettingsRepository extends DriftDatabaseRepository {
return instance;
}
AppConfig _appConfig = const .new();
AppConfig get appConfig => _appConfig;
static Future<SettingsRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = SettingsRepository._(db);
@@ -31,7 +29,20 @@ class SettingsRepository extends DriftDatabaseRepository {
return _instance!;
}
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
@override
List<SettingsKey> get keys => SettingsKey.values;
@override
Object decodeValue(SettingsKey key, String raw) => key.decode(raw);
@override
AppConfig buildSnapshot(Map<SettingsKey, Object?> overrides) => AppConfig.fromEntries(overrides);
@override
Selectable<({String key, String? value})> selectable() =>
_db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value));
AppConfig get appConfig => snapshot;
Future<void> clear(Iterable<SettingsKey> keys) async {
if (keys.isEmpty) {
@@ -41,13 +52,15 @@ class SettingsRepository extends DriftDatabaseRepository {
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
var config = snapshot;
for (final key in keys) {
_appConfig = _appConfig.write(key, defaultConfig.read(key));
config = config.write(key, defaultConfig.read(key));
}
snapshot = config;
}
Future<void> write<T, U extends T>(SettingsKey<T> key, U value) async {
if (value == _appConfig.read(key)) {
if (value == snapshot.read(key)) {
return;
}
@@ -65,29 +78,8 @@ class SettingsRepository extends DriftDatabaseRepository {
.insertOnConflictUpdate(
SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
);
_appConfig = _appConfig.write(key, value);
snapshot = snapshot.write(key, value);
}
Stream<AppConfig> watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) {
_applyOverrides(rows);
return _appConfig;
});
void _applyOverrides(List<SettingsEntityData> rows) {
_appConfig = AppConfig.fromEntries(
rows.fold({}, (overrides, row) {
final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key);
if (metadataKey == null) {
return overrides;
}
Object? decodedValue;
if (row.value != null) {
decodedValue = metadataKey.decode(row.value!);
}
return {...overrides, metadataKey: decodedValue};
}),
);
}
Stream<AppConfig> watchConfig() => watchSnapshot();
}

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
@@ -33,7 +33,7 @@ class PartnerPage extends ConsumerWidget {
title: Text(context.t.partners),
elevation: 0,
centerTitle: false,
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
actions: const [PartnerAddAction(display: ActionDisplay.iconButton)],
),
body: sharedByAsync.when(
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
@@ -60,7 +60,7 @@ class _EmptyPartners extends StatelessWidget {
),
const Align(
alignment: .center,
child: ActionButtonWidget(action: PartnerAddAction()),
child: PartnerAddAction(),
),
],
),
@@ -88,8 +88,10 @@ class PartnerSharedByList extends StatelessWidget {
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: ActionIconButtonWidget(
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
trailing: PartnerRemoveAction(
sharedWithId: partner.id,
partnerName: partner.name,
display: ActionDisplay.iconButton,
),
);
},

View File

@@ -1,32 +1,17 @@
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/user.model.dart';
import 'package:flutter/widgets.dart' hide Action;
class ActionScope {
final BuildContext context;
final WidgetRef ref;
final UserDto authUser;
enum ActionDisplay { button, iconButton, menuItem, columnButton }
const ActionScope({required this.context, required this.ref, required this.authUser});
}
abstract class BaseAction {
const BaseAction();
IconData get icon;
String label(ActionScope scope);
bool isVisible(ActionScope scope) => true;
Future<void> onAction(ActionScope scope);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
final Iterable<BaseAsset> assets;
const AssetAction({required this.assets});
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
class Action {
final IconData icon;
final bool isVisible;
final String label;
final Future<void> Function() onAction;
const Action({
required this.icon,
this.isVisible = true,
required this.label,
required this.onAction,
});
}

View File

@@ -1,95 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
class _ActionWidgetScope {
final String label;
final VoidCallback onAction;
abstract class ActionWidget extends ConsumerWidget {
final ActionDisplay display;
const _ActionWidgetScope({required this.label, required this.onAction});
}
const ActionWidget({super.key, this.display = ActionDisplay.button});
class _ActionWidget extends ConsumerWidget {
final BaseAction action;
final Widget Function(_ActionWidgetScope context) builder;
const _ActionWidget({required this.action, required this.builder});
Future<void> _onAction(ActionScope scope) async {
try {
await action.onAction(scope);
} catch (error, stackTrace) {
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}
Action resolve(BuildContext context, WidgetRef ref);
@override
Widget build(BuildContext context, WidgetRef ref) {
final authUser = ref.watch(currentUserProvider);
if (authUser == null) {
final action = resolve(context, ref);
if (!action.isVisible) {
return const SizedBox.shrink();
}
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
if (!action.isVisible(scope)) {
return const SizedBox.shrink();
}
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
return switch (display) {
ActionDisplay.button =>
ImmichTextButton(labelText: action.label, icon: action.icon, onPressed: action.onAction),
ActionDisplay.iconButton => ImmichIconButton(icon: action.icon, onPressed: action.onAction),
ActionDisplay.menuItem =>
ImmichMenuItem(icon: action.icon, label: action.label, onPressed: action.onAction),
ActionDisplay.columnButton =>
ImmichColumnButton(icon: action.icon, label: action.label, onPressed: action.onAction),
};
}
}
class ActionIconButtonWidget extends StatelessWidget {
final BaseAction action;
final ImmichVariant variant;
const ActionIconButtonWidget({super.key, required this.action, this.variant = .ghost});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
);
}
class ActionButtonWidget extends StatelessWidget {
final BaseAction action;
final ImmichVariant variant;
const ActionButtonWidget({super.key, required this.action, this.variant = .ghost});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) =>
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
);
}
class ActionColumnButtonWidget extends StatelessWidget {
final BaseAction action;
const ActionColumnButtonWidget({super.key, required this.action});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
class ActionMenuItemWidget extends StatelessWidget {
final BaseAction action;
const ActionMenuItemWidget({super.key, required this.action});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
);
}

View File

@@ -0,0 +1,33 @@
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/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
typedef AssetActionSelection = ({Set<BaseAsset> assets, void Function() clearSelect});
final assetsActionProvider = Provider.family<AssetActionSelection, ActionSource>((ref, source) {
final assets = switch (source) {
ActionSource.timeline => ref.watch(multiSelectProvider.select((s) => s.selectedAssets)),
ActionSource.viewer => switch (ref.watch(assetViewerProvider.select((s) => s.currentAsset))) {
BaseAsset asset => {asset},
null => const <BaseAsset>{},
},
};
return (
assets: assets,
clearSelect: () {
if (source == ActionSource.timeline) {
ref.read(multiSelectProvider.notifier).reset();
}
},
);
});
abstract class AssetAction extends ActionWidget {
final ActionSource source;
const AssetAction({super.key, super.display, this.source = ActionSource.timeline});
}

View File

@@ -1,27 +1,30 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/asset_action.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends AssetAction<BaseAsset> {
const AssetDebugAction({required super.assets});
class AssetDebugAction extends AssetAction {
const AssetDebugAction({super.key, super.display, super.source});
@override
IconData get icon => Icons.help_outline_rounded;
Action resolve(BuildContext context, WidgetRef ref) {
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
@override
String label(ActionScope scope) => scope.context.t.troubleshoot;
@override
bool isVisible(ActionScope scope) =>
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
@override
Future<void> onAction(ActionScope scope) async =>
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
return Action(
icon: Icons.help_outline_rounded,
isVisible: assets.length == 1 && advancedTroubleshooting,
label: context.t.troubleshoot,
onAction: () async {
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
clearSelect();
},
);
}
}

View File

@@ -1,38 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/asset_action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_ui/immich_ui.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool favorite;
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
class FavoriteAction extends AssetAction {
const FavoriteAction({super.key, super.display, super.source});
@override
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
Action resolve(BuildContext context, WidgetRef ref) {
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
final userId = ref.read(currentUserProvider)?.id;
final targets = userId == null
? const <RemoteAsset>[]
: AssetFilter(assets).owned(userId).favorite(isFavorite: false).toList(growable: false);
@override
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
final message = favorite
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
snackbar.success(message);
return Action(
icon: Icons.favorite_border_rounded,
isVisible: targets.isNotEmpty,
label: context.t.favorite,
onAction: () async {
final ids = targets.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(ids, true);
snackbar.success(StaticTranslations.instance.favorite_action_prompt(count: ids.length));
clearSelect();
},
);
}
}
class UnfavoriteAction extends AssetAction {
const UnfavoriteAction({super.key, super.display, super.source});
@override
Action resolve(BuildContext context, WidgetRef ref) {
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
final userId = ref.read(currentUserProvider)?.id;
final targets = userId == null
? const <RemoteAsset>[]
: AssetFilter(assets).owned(userId).favorite(isFavorite: true).toList(growable: false);
return Action(
icon: Icons.favorite_rounded,
isVisible: targets.isNotEmpty,
label: context.t.unfavorite,
onAction: () async {
final ids = targets.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(ids, false);
snackbar.success(StaticTranslations.instance.unfavorite_action_prompt(count: ids.length));
clearSelect();
},
);
}
}

View File

@@ -1,69 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class PartnerAddAction extends BaseAction {
const PartnerAddAction();
class PartnerAddAction extends ActionWidget {
const PartnerAddAction({super.key, super.display});
@override
IconData get icon => Icons.person_add_rounded;
Action resolve(BuildContext context, WidgetRef ref) {
return Action(
icon: Icons.person_add_rounded,
label: context.t.add_partner,
onAction: () async {
final authUser = ref.read(currentUserProvider);
if (authUser == null) {
return;
}
@override
String label(ActionScope scope) => scope.context.t.add_partner;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
return;
}
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:context, :ref, :authUser) = scope;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
return;
}
await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id);
await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id);
},
);
}
}
class PartnerRemoveAction extends BaseAction {
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName});
class PartnerRemoveAction extends ActionWidget {
final String sharedWithId;
final String partnerName;
@override
IconData get icon => Icons.person_remove_rounded;
const PartnerRemoveAction({super.key, super.display, required this.sharedWithId, required this.partnerName});
@override
String label(ActionScope scope) => scope.context.t.remove;
Action resolve(BuildContext context, WidgetRef ref) {
return Action(
icon: Icons.person_remove_rounded,
label: context.t.remove,
onAction: () async {
final authUser = ref.read(currentUserProvider);
if (authUser == null) {
return;
}
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:context, :ref, :authUser) = scope;
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: context.t.stop_photo_sharing,
content: context.t.partner_page_stop_sharing_content(partner: partnerName),
),
);
if (confirmed != true) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: context.t.stop_photo_sharing,
content: context.t.partner_page_stop_sharing_content(partner: partnerName),
),
await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId);
},
);
if (confirmed != true) {
return;
}
await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId);
}
}
@visibleForTesting
final candidatesStateProvider = StreamProvider.autoDispose<Iterable<User>>((ref) {
final currentUser = ref.watch(currentUserProvider);
// TODO: Refactor with a route guard to avoid this check in every provider
if (currentUser == null) {
return const Stream.empty();
}

View File

@@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TimelineAction extends BaseAction {
final BaseAction action;
const TimelineAction({required this.action});
@override
IconData get icon => action.icon;
@override
String label(ActionScope scope) => action.label(scope);
@override
bool isVisible(ActionScope scope) => action.isVisible(scope);
@override
Future<void> onAction(ActionScope scope) async {
await action.onAction(scope);
scope.ref.read(multiSelectProvider.notifier).reset();
}
}

View File

@@ -26,6 +26,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
FocusNode albumTitleTextFieldFocusNode = FocusNode();
FocusNode albumDescriptionTextFieldFocusNode = FocusNode();
bool isAlbumTitleTextFieldFocus = false;
bool isCreatingAlbum = false;
Set<BaseAsset> selectedAssets = {};
@override
@@ -48,7 +49,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
super.dispose();
}
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty;
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty && !isCreatingAlbum;
String _getEffectiveTitle() {
return albumTitleController.text.isNotEmpty
@@ -167,7 +168,12 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
}
Future<void> createAlbum() async {
if (isCreatingAlbum) {
return;
}
onBackgroundTapped();
setState(() => isCreatingAlbum = true);
final title = _getEffectiveTitle().trim();
@@ -187,6 +193,10 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
if (context.mounted) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t());
}
} finally {
if (mounted) {
setState(() => isCreatingAlbum = false);
}
}
}

View File

@@ -51,6 +51,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
int? _crossfadeFromIndex;
int? _crossfadeToIndex;
int _zoomCycle = 0;
bool _disableAnimations = false;
@override
initState() {
@@ -70,6 +71,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
unawaited(WakelockPlus.enable());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_disableAnimations = MediaQuery.disableAnimationsOf(context);
}
@override
dispose() {
_timer.cancel();
@@ -166,6 +173,11 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _crossFadeToPage(int page) {
if (_disableAnimations) {
_pageController.jumpToPage(page);
return;
}
final previousIndex = _index;
_pageController.jumpToPage(page);
setState(() {
@@ -273,19 +285,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
if (asset.isImage) {
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
return TweenAnimationBuilder(
return _SlideshowProgressBar(
key: Key(_index.toString()),
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: value,
),
durationMs: _config.duration * 1000,
elapsedMs: _stopwatch.elapsedMilliseconds,
paused: _paused,
color: context.colorScheme.primary,
);
} else {
return LinearProgressIndicator(
@@ -334,6 +339,21 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
if (asset.isImage) {
PhotoView buildPhotoView(PhotoViewComputedScale initialScale) => PhotoView(
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: initialScale,
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
);
if (_disableAnimations) {
return buildPhotoView(scale);
}
final zoomOut = _zoomCycle.isOdd;
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
@@ -349,16 +369,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
: 1.0,
),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => PhotoView(
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + value * _kenBurnsZoom),
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
),
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
);
} else {
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
@@ -463,3 +474,75 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
);
}
}
/// Progress bar for image slides, driven by an explicit [AnimationController].
///
/// [TweenAnimationBuilder] creates its controller internally with the default
/// [AnimationBehavior.normal], which makes it run ~20x too fast while the system
/// "reduce motion" setting is on (flutter/flutter#164287). This owns its
/// controller so it can use [AnimationBehavior.preserve] and animate at the real
/// slide duration regardless of that setting.
class _SlideshowProgressBar extends StatefulWidget {
final int durationMs;
final int elapsedMs;
final bool paused;
final Color color;
const _SlideshowProgressBar({
super.key,
required this.durationMs,
required this.elapsedMs,
required this.paused,
required this.color,
});
@override
State<_SlideshowProgressBar> createState() => _SlideshowProgressBarState();
}
class _SlideshowProgressBarState extends State<_SlideshowProgressBar> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: widget.durationMs),
animationBehavior: AnimationBehavior.preserve,
)..value = (widget.elapsedMs / widget.durationMs).clamp(0.0, 1.0);
if (!widget.paused) {
_controller.forward();
}
}
@override
void didUpdateWidget(_SlideshowProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.durationMs != oldWidget.durationMs) {
_controller.duration = Duration(milliseconds: widget.durationMs);
}
if (widget.paused != oldWidget.paused) {
widget.paused ? _controller.stop() : _controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) => LinearProgressIndicator(
color: widget.color,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: _controller.value,
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -154,12 +155,8 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
return;
}
if (result.count == 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
);
} else {
// Only report the failure when nothing was added; if some succeeded we show "added".
if (result.count > 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
@@ -167,6 +164,17 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
// Refresh the "Appears in" list on the asset's info panel.
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
} else if (result.failedCount > 0) {
ImmichToast.show(
context: context,
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failedCount}),
toastType: ToastType.error,
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
);
}
if (!context.mounted) {

View File

@@ -2,11 +2,13 @@ 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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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';
@@ -59,13 +61,19 @@ class DeleteActionButton extends ConsumerWidget {
}
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final currentAsset = ref.read(assetViewerProvider).currentAsset;
final stackIndex = ref.read(assetViewerProvider).stackIndex;
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer && result.success) {
final shouldRefreshStack = currentAsset is RemoteAsset && currentAsset.stackId != null;
EventStream.shared.emit(
shouldRefreshStack ? ViewerStackAssetDeletedEvent(stackIndex: stackIndex) : const ViewerReloadAssetEvent(),
);
}
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -413,7 +413,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex);
final safeStackIndex = stackIndex.clamp(0, stackChildren.length - 1);
displayAsset = stackChildren.elementAt(safeStackIndex);
}
final isCurrent = currentAsset != null && currentAsset.refersToSameAsset(displayAsset);

View File

@@ -11,6 +11,10 @@ class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAs
return ref.watch(assetServiceProvider).getStack(asset);
}
void setStack(List<RemoteAsset> stack) {
state = AsyncData(stack);
}
}
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -221,6 +222,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_onTimelineReloadEvent();
case ViewerReloadAssetEvent():
_onViewerReloadEvent();
case ViewerStackAssetDeletedEvent event:
_onViewerStackAssetDeletedEvent(event);
default:
}
}
@@ -236,6 +239,33 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_onAssetChanged(target);
}
Future<void> _onViewerStackAssetDeletedEvent(ViewerStackAssetDeletedEvent event) async {
final timelineAsset = ref.read(timelineServiceProvider).getAssetSafe(_currentPage);
if (timelineAsset == null) {
_onViewerReloadEvent();
return;
}
final stackProvider = stackChildrenNotifier(timelineAsset);
ref.invalidate(stackProvider);
final stack = await ref.read(stackProvider.future);
if (!mounted) {
return;
}
if (stack.isEmpty) {
_onViewerReloadEvent();
return;
}
final targetIndex = math.min(event.stackIndex, stack.length - 1);
ref.read(assetViewerProvider.notifier)
..setAsset(stack[targetIndex])
..setStackIndex(targetIndex);
}
void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets;
@@ -252,6 +282,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (index != _currentPage) {
_pageController.jumpToPage(index);
_onAssetChanged(index);
} else if (currentAsset is RemoteAsset && currentAsset.stackId != null && assetIndex == null) {
final timelineAsset = timelineService.getAssetSafe(index);
if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) {
_onAssetChanged(index);
}
} else if (currentAsset != null && assetIndex == null) {
_onAssetChanged(index);
}

View File

@@ -101,7 +101,7 @@ class ViewerBottomBar extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
OcrToggleButton(asset: asset),
if (asset.isImage) OcrToggleButton(asset: asset),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),

View File

@@ -2,9 +2,10 @@ 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';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
@@ -43,7 +44,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final assetForAction = [asset];
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
@@ -61,7 +61,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
const FavoriteAction(source: ActionSource.viewer, display: ActionDisplay.iconButton),
const UnfavoriteAction(source: ActionSource.viewer, display: ActionDisplay.iconButton),
ImmichColorOverride(color: null, child: ViewerKebabMenu(originalTheme: originalTheme)),
];

View File

@@ -3,9 +3,8 @@ 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/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.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/download_action_button.widget.dart';
@@ -76,9 +75,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
@@ -89,7 +85,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)

View File

@@ -4,9 +4,8 @@ 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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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';
@@ -41,7 +40,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
final result = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
@@ -52,24 +51,28 @@ class FavoriteBottomSheet extends ConsumerWidget {
);
}
if (addedCount != remoteAssets.length) {
// Only report the failure when nothing was added; if some succeeded we show "added".
if (result.added > 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
);
} else if (result.failed > 0) {
ImmichToast.show(
context: context,
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failed}),
toastType: ToastType.error,
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
);
}
ref.read(multiSelectProvider.notifier).reset();
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
maxChildSize: 0.7,
@@ -78,7 +81,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable

View File

@@ -3,9 +3,8 @@ 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/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -83,9 +82,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [AssetDebugAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: widget.minChildSize ?? 0.15,
@@ -93,7 +89,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const AssetDebugAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),

View File

@@ -1,23 +1,76 @@
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';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class PartnerDetailBottomSheet extends ConsumerWidget {
class PartnerDetailBottomSheet extends ConsumerStatefulWidget {
const PartnerDetailBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const BaseBottomSheet(
ConsumerState<PartnerDetailBottomSheet> createState() => _PartnerDetailBottomSheetState();
}
class _PartnerDetailBottomSheetState extends ConsumerState<PartnerDetailBottomSheet> {
late final DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
return;
}
ImmichToast.show(
context: context,
msg: result.count == 0
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
maxChildSize: 0.4,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
actions: const [
ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
);
}
}

View File

@@ -3,9 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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';
@@ -85,9 +84,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.22,
@@ -101,7 +97,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[

View File

@@ -16,7 +16,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -133,8 +132,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
// When the AssetViewer is open, the DriftMap route stays alive in the background.
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
final currentRoute = ref.read(currentRouteNameProvider);
if (currentRoute == AssetViewerRoute.name) {
if (ref.read(isAssetViewerOpenProvider)) {
return;
}
@@ -183,6 +181,11 @@ class _DriftMapState extends ConsumerState<DriftMap> {
@override
Widget build(BuildContext context) {
ref.listen<bool>(isAssetViewerOpenProvider, (previous, current) {
if (previous == true && !current) {
_debouncer.run(() => setBounds(forceReload: true));
}
});
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),

View File

@@ -1,9 +1,6 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/download/download_state.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/download.service.dart';
class DownloadStateNotifier extends StateNotifier<DownloadState> {
@@ -17,79 +14,9 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
taskProgress: <String, DownloadInfo>{},
),
) {
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
_downloadService.onTaskProgress = _taskProgressCallback;
}
void _updateDownloadStatus(String taskId, TaskStatus status) {
if (status == TaskStatus.canceled) {
return;
}
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..addAll({
taskId: DownloadInfo(
progress: state.taskProgress[taskId]?.progress ?? 0,
fileName: state.taskProgress[taskId]?.fileName ?? '',
status: status,
),
}),
);
}
// Download live photo callback
void _downloadLivePhotoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
if (update.task.metaData.isEmpty) {
return;
}
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
_downloadService.saveLivePhotos(update.task, livePhotosId);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download image callback
void _downloadImageCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveImageWithPath(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download video callback
void _downloadVideoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveVideo(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is canceled or completed
if (update.progress == -2 || update.progress == -1) {
@@ -110,20 +37,6 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
);
}
void _onDownloadComplete(String id) {
Future.delayed(const Duration(seconds: 2), () {
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..remove(id),
);
if (state.taskProgress.isEmpty) {
state = state.copyWith(showProgress: false);
}
});
}
void cancelDownload(String id) async {
final isCanceled = await _downloadService.cancelDownload(id);

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
@@ -10,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -23,7 +21,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
@@ -37,18 +34,25 @@ class ActionResult {
final bool success;
final String? error;
final List<String> remoteAssetIds;
final int failedCount;
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
const ActionResult({
required this.count,
required this.success,
this.error,
this.remoteAssetIds = const [],
this.failedCount = 0,
});
@override
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
String toString() =>
'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds, failedCount: $failedCount)';
}
class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier');
late ActionService _service;
late ForegroundUploadService _foregroundUploadService;
late DownloadService _downloadService;
late AssetService _assetService;
ActionNotifier() : super();
@@ -58,29 +62,6 @@ class ActionNotifier extends Notifier<void> {
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_assetService = ref.watch(assetServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
}
void _downloadImageCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveImageWithPath(update.task);
}
}
void _downloadVideoCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveVideo(update.task);
}
}
void _downloadLivePhotoCallback(TaskStatusUpdate update) async {
if (update.status == TaskStatus.complete) {
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
unawaited(_downloadService.saveLivePhotos(update.task, livePhotosId));
}
}
List<String> _getRemoteIdsForSource(ActionSource source) {
@@ -393,9 +374,12 @@ class ActionNotifier extends Notifier<void> {
final albumNotifier = ref.read(remoteAlbumProvider.notifier);
int addedRemote = 0;
int failedRemote = 0;
if (remoteIds.isNotEmpty) {
try {
addedRemote = await albumNotifier.addAssets(album.id, remoteIds);
final result = await albumNotifier.addAssets(album.id, remoteIds);
addedRemote = result.added;
failedRemote = result.failed;
} catch (error, stack) {
_logger.severe('Failed to add assets to album ${album.id}', error, stack);
return ActionResult(count: 0, success: false, error: error.toString());
@@ -409,7 +393,7 @@ class ActionNotifier extends Notifier<void> {
}
if (localAssets.isEmpty) {
return ActionResult(count: addedRemote, success: true);
return ActionResult(count: addedRemote, success: true, failedCount: failedRemote);
}
final uploadResult = await upload(
@@ -424,6 +408,7 @@ class ActionNotifier extends Notifier<void> {
count: addedRemote + uploadResult.count,
success: uploadResult.success,
error: uploadResult.error,
failedCount: failedRemote,
);
}

View File

@@ -200,12 +200,12 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return _remoteAlbumService.getAssets(albumId);
}
Future<int> addAssets(String albumId, List<String> assetIds) async {
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (added > 0) {
Future<({int added, int failed})> addAssets(String albumId, List<String> assetIds) async {
final result = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (result.added > 0) {
await _refreshAlbumInState(albumId);
}
return added;
return result;
}
/// Links a freshly-uploaded local asset to an album using its new remote ID,
@@ -313,9 +313,9 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
}
}
final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async {
final remoteAlbumDateRangeProvider = StreamProvider.autoDispose.family<(DateTime, DateTime), String>((ref, albumId) {
final service = ref.watch(remoteAlbumServiceProvider);
return service.getDateRange(albumId);
return service.watchDateRange(albumId);
});
final remoteAlbumSharedUsersProvider = FutureProvider.autoDispose.family<List<UserDto>, String>((ref, albumId) async {

View File

@@ -1,7 +1,19 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
@visibleForTesting
bool isRouteInStack(Ref ref, String routeName) {
final router = ref.watch(appRouterProvider);
void onChange() => ref.invalidateSelf();
router.addListener(onChange);
ref.onDispose(() => router.removeListener(onChange));
return router.stackData.any((route) => route.name == routeName);
}
final inLockedViewProvider = Provider<bool>((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name));
final isAssetViewerOpenProvider = Provider<bool>((ref) => isRouteInStack(ref, AssetViewerRoute.name));
final inLockedViewProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);

View File

@@ -103,6 +103,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1);
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_album_update', _handleAlbumUpdate);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {
@@ -184,6 +185,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
}
void _handleAlbumUpdate(dynamic _) {
unawaited(_ref.read(backgroundSyncProvider).syncRemote());
}
void _handleSyncAssetEditReadyV2(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
}

View File

@@ -27,10 +27,12 @@ class DownloadRepository {
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
// #29900: `taskStatusCallback` is called before the DB has been updated, causing a race between the two Live Photo tasks
// This callback instead listens directly to DB updates
void Function(TaskRecord)? onLivePhotoRecordComplete;
DownloadRepository() {
_downloader.registerCallbacks(
group: kDownloadGroupImage,
@@ -46,9 +48,12 @@ class DownloadRepository {
_downloader.registerCallbacks(
group: kDownloadGroupLivePhoto,
taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
_downloader.database.updates
.where((record) => record.group == kDownloadGroupLivePhoto && record.status == TaskStatus.complete)
.listen((record) => onLivePhotoRecordComplete?.call(record));
}
Future<List<bool>> downloadAll(List<DownloadTask> tasks) {

View File

@@ -59,7 +59,7 @@ class DriftAlbumApiRepository extends ApiRepository {
for (final dto in response) {
if (dto.success) {
added.add(dto.id);
} else {
} else if (dto.error.orElse(null) != BulkIdErrorReason.duplicate) {
failed.add(dto.id);
}
}

View File

@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppNavigationObserver extends AutoRouterObserver {
/// Riverpod Instance
@@ -12,35 +11,12 @@ class AppNavigationObserver extends AutoRouterObserver {
AppNavigationObserver({required this.ref});
@override
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false));
}
@override
void didPush(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(route, previousRoute);
Future(() {
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
});
}
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
final isInLockedView = ref.read(inLockedViewProvider);
final isFromLockedViewToDetailView =
route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name;
final isFromDetailViewToInfoPanelView =
route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView;
if (route.settings.name == DriftLockedFolderRoute.name ||
isFromLockedViewToDetailView ||
isFromDetailViewToInfoPanelView) {
Future(() => ref.read(inLockedViewProvider.notifier).state = true);
} else {
Future(() => ref.read(inLockedViewProvider.notifier).state = false);
}
}
}

View File

@@ -290,13 +290,10 @@ class BackgroundUploadService {
return null;
}
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
final originalFileName = p.setExtension(fileName, extension);
String metadata = UploadTaskMetadata(
localAssetId: asset.id,

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
@@ -18,14 +20,27 @@ class DownloadService {
final Logger _log = Logger("DownloadService");
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
/// Active Live Photo IDs undergoing saving
final Set<String> _savingLivePhotoIds = {};
DownloadService(this._fileMediaRepository, this._downloadRepository) {
_downloadRepository.onImageDownloadStatus = _onImageDownloadCallback;
_downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback;
_downloadRepository.onLivePhotoDownloadStatus = _onLivePhotoDownloadCallback;
_downloadRepository.onTaskProgress = _onTaskProgressCallback;
_downloadRepository.onLivePhotoRecordComplete = _onLivePhotoRecordComplete;
unawaited(_savePreviouslyCompletedLivePhotos());
}
Future<void> _savePreviouslyCompletedLivePhotos() async {
// Specifically fetch Live Photo video components only, as to not double fetch assets
final records = await _downloadRepository.getLiveVideoTasks();
final completedIds = records.map((record) => LivePhotosMetadata.fromJson(record.task.metaData).id).toSet();
for (final id in completedIds) {
await _saveLivePhotos(id);
}
}
void _onTaskProgressCallback(TaskProgressUpdate update) {
@@ -33,18 +48,27 @@ class DownloadService {
}
void _onImageDownloadCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
unawaited(_saveImageWithPath(update.task));
}
onImageDownloadStatus?.call(update);
}
void _onVideoDownloadCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
unawaited(_saveVideo(update.task));
}
onVideoDownloadStatus?.call(update);
}
void _onLivePhotoDownloadCallback(TaskStatusUpdate update) {
onLivePhotoDownloadStatus?.call(update);
void _onLivePhotoRecordComplete(TaskRecord record) async {
final livePhotosId = LivePhotosMetadata.fromJson(record.task.metaData).id;
await _saveLivePhotos(livePhotosId);
}
Future<bool> saveImageWithPath(Task task) async {
Future<bool> _saveImageWithPath(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
@@ -65,7 +89,7 @@ class DownloadService {
}
}
Future<bool> saveVideo(Task task) async {
Future<bool> _saveVideo(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
@@ -83,14 +107,21 @@ class DownloadService {
}
}
Future<bool> saveLivePhotos(Task task, String livePhotosId) async {
Future<bool> _saveLivePhotos(String livePhotosId) async {
final records = await _downloadRepository.getLiveVideoTasks();
if (records.length < 2) {
final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image);
final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video);
if (imageRecord == null || videoRecord == null) {
return false;
}
final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image);
final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video);
// Write semaphore for this `livePhotoId`
if (!_savingLivePhotoIds.add(livePhotosId)) {
return false;
}
final title = imageRecord.task.filename;
final imageFilePath = await imageRecord.task.filePath();
final videoFilePath = await videoRecord.task.filePath();
@@ -98,14 +129,14 @@ class DownloadService {
final result = await _fileMediaRepository.saveLivePhoto(
image: File(imageFilePath),
video: File(videoFilePath),
title: task.filename,
title: title,
);
return result != null;
} on PlatformException catch (error, stack) {
// Handle saving MotionPhotos on iOS
if (error.code.startsWith('PHPhotosErrorDomain')) {
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: task.filename);
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: title);
return result != null;
}
_log.severe("Error saving live photo", error, stack);
@@ -125,6 +156,7 @@ class DownloadService {
}
await _downloadRepository.deleteRecordsWithIds([imageRecord.task.taskId, videoRecord.task.taskId]);
_savingLivePhotoIds.remove(livePhotosId);
}
}
@@ -133,8 +165,8 @@ class DownloadService {
}
}
TaskRecord _findTaskRecord(List<TaskRecord> records, String livePhotosId, LivePhotosPart part) {
return records.firstWhere((record) {
TaskRecord? _findTaskRecord(List<TaskRecord> records, String livePhotosId, LivePhotosPart part) {
return records.firstWhereOrNull((record) {
final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
return metadata.id == livePhotosId && metadata.part == part;
});

View File

@@ -309,17 +309,10 @@ class ForegroundUploadService {
return;
}
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
/// Handle special file name from DJI or Fusion app
/// If the file name has no extension, likely due to special renaming template by specific apps
/// we append the original extension from the asset name
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
final originalFileName = p.setExtension(fileName, extension);
final deviceId = Store.get(StoreKey.deviceId);
final fields = {

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
@@ -192,7 +192,7 @@ enum ActionButtonType {
bool menuItem = false,
]) {
return switch (this) {
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
ActionButtonType.advancedInfo => AssetDebugAction(source: context.source, display: ActionDisplay.menuItem),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.shareLink => ShareLinkActionButton(
source: context.source,

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -10,6 +9,7 @@ import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -255,11 +255,11 @@ class _BackupDelaySlider extends ConsumerWidget {
_ => 600,
};
static String formatBackupDelaySliderValue(int v) => switch (v) {
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
static String formatBackupDelaySliderValue(BuildContext context, int v) => switch (v) {
0 => context.t.setting_notifications_notify_seconds(count: 5),
1 => context.t.setting_notifications_notify_seconds(count: 30),
2 => context.t.setting_notifications_notify_minutes(count: 2),
_ => context.t.setting_notifications_notify_minutes(count: 10),
};
@override
@@ -272,8 +272,8 @@ class _BackupDelaySlider extends ConsumerWidget {
Padding(
padding: const EdgeInsets.only(left: 24.0, top: 8.0),
child: Text(
'backup_controller_page_background_delay'.tr(
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
context.t.backup_controller_page_background_delay(
duration: formatBackupDelaySliderValue(context, currentValue),
),
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
@@ -291,7 +291,7 @@ class _BackupDelaySlider extends ConsumerWidget {
max: 3.0,
min: 0.0,
divisions: 3,
label: formatBackupDelaySliderValue(currentValue),
label: formatBackupDelaySliderValue(context, currentValue),
),
],
);

View File

@@ -58,7 +58,7 @@ dependencies:
path_provider: ^2.1.5
path_provider_foundation: ^2.6.0
permission_handler: ^11.4.0
photo_manager: ^3.9.0
photo_manager: 3.9.0
pinput: ^5.0.2
punycode: ^1.0.0
scroll_date_picker: ^3.8.0

View File

@@ -12,7 +12,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
@@ -20,8 +19,6 @@ class MockActionService extends Mock implements ActionService {}
class MockAssetService extends Mock implements AssetService {}
class MockDownloadService extends Mock implements DownloadService {}
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
class MockUserService extends Mock implements UserService {}
@@ -67,7 +64,6 @@ void main() {
overrides: [
actionServiceProvider.overrideWithValue(actionService),
assetServiceProvider.overrideWithValue(assetService),
downloadServiceProvider.overrideWithValue(MockDownloadService()),
foregroundUploadServiceProvider.overrideWithValue(MockForegroundUploadService()),
currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)),
],

View File

@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class _MockAlbumsApi extends Mock implements AlbumsApi {}
void main() {
late _MockAlbumsApi api;
late DriftAlbumApiRepository repo;
setUpAll(() {
registerFallbackValue(BulkIdsDto(ids: const []));
});
setUp(() {
api = _MockAlbumsApi();
repo = DriftAlbumApiRepository(api);
});
void stubResponse(List<BulkIdResponseDto> response) {
when(
() => api.addAssetsToAlbum(any(), any(), abortTrigger: any(named: 'abortTrigger')),
).thenAnswer((_) async => response);
}
test('no_permission failure surfaces as failed, not added (the #22342 bug)', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)),
]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, isEmpty);
expect(result.failed, ['a1']);
});
test('duplicate is neither added nor failed (genuinely already in album)', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)),
]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, isEmpty);
expect(result.failed, isEmpty);
});
test('success is added', () async {
stubResponse([BulkIdResponseDto(id: 'a1', success: true)]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, ['a1']);
expect(result.failed, isEmpty);
});
test('not_found and unknown count as failures', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.notFound)),
BulkIdResponseDto(id: 'a2', success: false, error: const Optional.present(BulkIdErrorReason.unknown)),
]);
final result = await repo.addAssets('album1', ['a1', 'a2']);
expect(result.added, isEmpty);
expect(result.failed, ['a1', 'a2']);
});
test('mixed: added kept, no_permission failed, duplicate dropped', () async {
stubResponse([
BulkIdResponseDto(id: 'ok', success: true),
BulkIdResponseDto(id: 'perm', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)),
BulkIdResponseDto(id: 'dup', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)),
]);
final result = await repo.addAssets('album1', ['ok', 'perm', 'dup']);
expect(result.added, ['ok']);
expect(result.failed, ['perm']);
});
}

View File

@@ -136,6 +136,51 @@ void main() {
expect(task, isNotNull);
expect(task!.fields.containsKey('visibility'), isFalse);
});
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/IMG_6499.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_6499.jpg'));
});
test('keeps the .dng extension for a genuine RAW original', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/IMG_5210.dng');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_5210.dng'));
});
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/DJI_0001');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('DJI_0001.jpg'));
});
});
group('getLivePhotoUploadTask', () {

View File

@@ -81,6 +81,24 @@ void main() {
return captured;
}
List<String> captureOriginalFileNames() {
final captured = <String>[];
when(
() => mockUploadRepository.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((invocation) async {
captured.add(invocation.namedArguments[#originalFileName] as String);
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
});
return captured;
}
group('uploadSingleAsset', () {
test('should upload the motion part hidden and keep the still image visible', () async {
final asset = LocalAssetStub.image1;
@@ -124,5 +142,59 @@ void main() {
expect(captured, hasLength(1));
expect(captured[0].containsKey('visibility'), isFalse);
});
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/IMG_6499.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['IMG_6499.jpg']));
});
test('keeps the .dng extension for a genuine RAW original', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/IMG_5210.dng');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['IMG_5210.dng']));
});
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/DJI_0001');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['DJI_0001.jpg']));
});
});
}

View File

@@ -1,8 +1,11 @@
import 'package:flutter_test/flutter_test.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/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_asset_factory.dart';
@@ -20,11 +23,18 @@ void main() {
context.dispose();
});
List<Override> selection(Set<BaseAsset> assets) => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})),
),
];
group('AssetDebugAction', () {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
const AssetDebugAction(display: ActionDisplay.iconButton),
overrides: selection({RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsOneWidget);
@@ -33,9 +43,8 @@ void main() {
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
const AssetDebugAction(display: ActionDisplay.iconButton),
overrides: selection({RemoteAssetFactory.create(), RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -45,7 +54,8 @@ void main() {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
const AssetDebugAction(display: ActionDisplay.iconButton),
overrides: selection({RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
@@ -24,28 +27,34 @@ void main() {
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
List<Override> selection(Set<BaseAsset> assets) => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})),
),
];
group('FavoriteAction', () {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({asset}),
);
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({mine, theirs}),
);
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
});
@@ -54,25 +63,51 @@ void main() {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({first, second}),
);
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
testWidgets('skips owned assets already favorited', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({stale, alreadyFavorite}),
);
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({owned()}),
);
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
});
group('UnfavoriteAction', () {
testWidgets('unfavorites the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(
context,
const UnfavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({asset}),
);
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
});
});
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:mocktail/mocktail.dart';
@@ -31,7 +32,7 @@ void main() {
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpTestAction(context, const PartnerAddAction(display: ActionDisplay.iconButton), overrides: overrides(candidates: [candidate]));
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
@@ -42,7 +43,7 @@ void main() {
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(
context,
const PartnerAddAction(),
const PartnerAddAction(display: ActionDisplay.iconButton),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
@@ -57,7 +58,7 @@ void main() {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, display: ActionDisplay.iconButton),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).last); // confirm
@@ -70,7 +71,7 @@ void main() {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, display: ActionDisplay.iconButton),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).first); // cancel

View File

@@ -1,108 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
final bool visible;
final Object? error;
bool ran = false;
bool? selectionDuringOnAction;
@override
IconData get icon => Icons.bolt;
@override
String label(ActionScope scope) => 'fake';
@override
bool isVisible(ActionScope scope) => visible;
@override
Future<void> onAction(ActionScope scope) async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
throw error!;
}
}
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
List<Override> overrides() => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
),
),
];
Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
container = ProviderScope.containerOf(innerContext, listen: false);
return const SizedBox.shrink();
},
),
overrides: overrides(),
);
return (scope, container);
}
group('TimelineAction', () {
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
final inner = _FakeAction();
final (scope, container) = await pumpScope(tester);
await TimelineAction(action: inner).onAction(scope);
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
expect(container.read(multiSelectProvider).isEnabled, isFalse);
});
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
final error = Exception('boom');
final inner = _FakeAction(error: error);
final (scope, container) = await pumpScope(tester);
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
expect(inner.ran, isTrue);
expect(container.read(multiSelectProvider).isEnabled, isTrue);
});
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsNothing);
});
});
}

View File

@@ -17,12 +17,10 @@ void main() {
group('PartnerSharedByList', () {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
final action = const PartnerAddAction();
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
expect(find.widgetWithIcon(TextButton, Icons.person_add_rounded), findsOneWidget);
});
testWidgets('renders a tile per partner with name and email', (tester) async {
@@ -39,9 +37,8 @@ void main() {
testWidgets('renders a remove action for each partner', (tester) async {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byIcon(action.icon), findsNWidgets(2));
expect(find.byIcon(Icons.person_remove_rounded), findsNWidgets(2));
});
});

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
@@ -98,10 +97,10 @@ extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestAction(
PresentationContext context,
BaseAction action, {
ActionWidget action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
await pumpTestWidget(context, action, overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
}

View File

@@ -47,7 +47,7 @@ program
.usage('[paths...] [options]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore <pattern>', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS'))
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(new Option('--skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
.addOption(
new Option('-a, --album', 'Automatically create albums based on folder name')

View File

@@ -308,6 +308,76 @@
},
"uiHints": ["Filter"]
},
{
"name": "assetExifFilter",
"title": "Filter by EXIF metadata",
"description": "Filter assets by their EXIF properties",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"property": {
"title": "Property",
"description": "EXIF property to match",
"type": "string",
"enum": [
"make",
"model",
"exifImageWidth",
"exifImageHeight",
"fileSizeInByte",
"orientation",
"lensModel",
"fNumber",
"focalLength",
"iso",
"description",
"fps",
"exposureTime",
"livePhotoCID",
"timeZone",
"projectionType",
"profileDescription",
"colorspace",
"bitsPerSample",
"rating"
],
"uiHint": {
"order": 1
}
},
"pattern": {
"type": "string",
"title": "Pattern",
"description": "Text or regex pattern to match against property value",
"uiHint": {
"order": 2
}
},
"matchType": {
"type": "string",
"title": "Match type",
"enum": ["contains", "startsWith", "exact", "regex"],
"default": "contains",
"description": "Type of pattern matching to perform",
"uiHint": {
"order": 3
}
},
"caseSensitive": {
"type": "boolean",
"default": false,
"title": "Case sensitive",
"description": "Whether matching should be case-sensitive",
"uiHint": {
"order": 4
}
}
},
"required": ["property", "pattern"]
},
"uiHints": ["Filter"]
},
{
"name": "assetArchive",
"title": "Archive asset",

View File

@@ -2,6 +2,42 @@ import { wrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type { Manifest } from '../dist/index.d.ts';
type MatchValueConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
caseSensitive?: boolean;
};
const matchValueResult = (value: string, config: MatchValueConfig) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const searchName = caseSensitive ? value : value.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(value) } };
}
default: {
return {};
}
}
};
const methods = wrapper<Manifest>({
assetAddToAlbums: ({ config, data, functions }) => {
const assetId = data.asset.id;
@@ -53,39 +89,7 @@ const methods = wrapper<Manifest>({
}
},
assetFileFilter: ({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false, usePath = false } = config;
const { asset } = data;
const fileName = usePath ? asset.originalPath : asset.originalFileName;
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
default: {
return {};
}
}
},
assetFileFilter: ({ data, config }) => matchValueResult(data.asset.originalFileName || '', config),
assetLocationFilter: ({ config, data }) => {
if (
@@ -124,6 +128,14 @@ const methods = wrapper<Manifest>({
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
},
assetExifFilter: ({ config, data }) => {
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
return { workflow: { continue: false } };
}
return matchValueResult(String(data.asset.exifInfo[config.property]), config);
},
assetDateFilter: ({ config, data }) => {
const assetDate = new Date(data.asset.localDateTime);
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
@@ -200,6 +212,7 @@ const {
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetExifFilter,
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
@@ -217,6 +230,7 @@ export {
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetExifFilter,
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,

View File

@@ -8,13 +8,13 @@ import {
} from 'src/commands/media-location.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
import { SchemaCheck } from 'src/commands/schema-check';
import { VersionCommand } from 'src/commands/version.command';
export const commandsAndQuestions = [
ResetAdminPasswordCommand,
PromptPasswordQuestions,
PromptPasswordResetQuestions,
PromptEmailQuestion,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,

View File

@@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { CliService } from 'src/services/cli.service';
const prompt = (inquirer: InquirerService) => {
return function ask(admin: UserAdminResponseDto) {
return (admin: UserAdminResponseDto) => {
const { id, oauthId, email, name } = admin;
console.log(`Found Admin:
- ID=${id}
@@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => {
- Email=${email}
- Name=${name}`);
return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password);
return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {});
};
};
@@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner {
}
}
@QuestionSet({ name: 'prompt-password' })
export class PromptPasswordQuestions {
@QuestionSet({ name: 'prompt-password-reset' })
export class PromptPasswordResetQuestions {
@Question({
message: 'Please choose a new password (optional)',
name: 'password',
name: 'newPassword',
})
parsePassword(value: string) {
return value;
}
@Question({
type: 'confirm',
message: 'Invalidate existing sessions?',
default: true,
name: 'invalidateSessions',
})
parseInvalidate(value: boolean): boolean {
return value;
}
}

View File

@@ -4,7 +4,8 @@
select
"id",
"expiresAt",
"pinExpiresAt"
"pinExpiresAt",
"oauthBearerToken"
from
"session"
where

View File

@@ -38,7 +38,7 @@ type EventMap = {
ConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// album events
AlbumUpdate: [{ id: string; recipientId: string }];
AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events

View File

@@ -83,7 +83,7 @@ export class OAuthRepository {
url: string,
expectedState: string,
codeVerifier: string,
): Promise<{ profile: OAuthProfile; sid?: string }> {
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
@@ -111,7 +111,7 @@ export class OAuthRepository {
}
}
return { profile, sid };
return { profile, sid, idToken: tokens.id_token };
} catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn(

View File

@@ -32,7 +32,7 @@ export class SessionRepository {
get(id: string) {
return this.db
.selectFrom('session')
.select(['id', 'expiresAt', 'pinExpiresAt'])
.select(['id', 'expiresAt', 'pinExpiresAt', 'oauthBearerToken'])
.where('id', '=', id)
.executeTakeFirst();
}

View File

@@ -37,6 +37,7 @@ export interface ClientEventMap {
on_asset_hidden: [string];
on_asset_restore: [string[]];
on_asset_stack_update: string[];
on_album_update: [string];
on_person_thumbnail: [string];
on_server_version: [ServerVersionResponseDto];
on_config_update: [];

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" ADD "oauthBearerToken" character varying;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" DROP COLUMN "oauthBearerToken";`.execute(db);
}

View File

@@ -55,4 +55,7 @@ export class SessionTable {
@Column({ nullable: true, index: true })
oauthSid!: string | null;
@Column({ nullable: true })
oauthBearerToken!: string | null;
}

View File

@@ -841,7 +841,8 @@ describe(AlbumService.name, () => {
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album.id,
recipientId: owner.id,
userIds: album.albumUsers.map(({ user }) => user.id),
recipientIds: [owner.id],
});
});
@@ -1091,11 +1092,13 @@ describe(AlbumService.name, () => {
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album1.id,
recipientId: owner1.id,
userIds: album1.albumUsers.map(({ user }) => user.id),
recipientIds: [owner1.id],
});
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album2.id,
recipientId: owner2.id,
userIds: album2.albumUsers.map(({ user }) => user.id),
recipientIds: [owner2.id],
});
});

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