Compare commits

..

1 Commits

Author SHA1 Message Date
shenlong-tanwen 3c9becd9ea replace drift_flutter with drift_sqlite_async 2026-05-15 16:02:52 +05:30
284 changed files with 9318 additions and 10055 deletions
@@ -16,7 +16,7 @@ services:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store - pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugin-core:/build/plugins/immich-plugin-core - ../packages/plugins:/build/corePlugin
immich-web: immich-web:
env_file: !reset [] env_file: !reset []
immich-machine-learning: immich-machine-learning:
+7 -7
View File
@@ -91,7 +91,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -159,14 +159,14 @@ jobs:
- name: Comment APK download link on PR - name: Comment APK download link on PR
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }} if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0 uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
env: env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }} APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
with: with:
id: mobile-android-apk github-token: ${{ steps.token.outputs.token }}
token: ${{ steps.token.outputs.token }} message-id: 'mobile-android-apk'
body: | message: |
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}` 📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
Download: ${{ env.APK_URL }} Download: ${{ env.APK_URL }}
@@ -216,7 +216,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -231,7 +231,7 @@ jobs:
run: mise //mobile:codegen:pigeon run: mise //mobile:codegen:pigeon
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0 uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
with: with:
ruby-version: '3.3' ruby-version: '3.3'
bundler-cache: true bundler-cache: true
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@6147a58e5d1249a12f42fc864ab791d571a30015 # v0.0.47 uses: oasdiff/oasdiff-action/breaking@26ccb332c67a45ca649de9faf60552ef1b8260d9 # v0.0.46
with: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
# ️ Command-line programs to run using the OS shell. # ️ 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 # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
+4 -3
View File
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -213,11 +213,12 @@ jobs:
run: 'mise run //deployment:tf apply' run: 'mise run //deployment:tf apply'
- name: Comment - name: Comment
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0 uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
if: ${{ steps.parameters.outputs.event == 'pr' }} if: ${{ steps.parameters.outputs.event == 'pr' }}
with: with:
id: docs-pr-url
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }} number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
body: | body: |
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }}) 📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
emojis: 'rocket'
body-include: '<!-- Docs PR URL -->'
+4 -3
View File
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -44,8 +44,9 @@ jobs:
run: 'mise run //deployment:tf destroy -- -refresh=false' run: 'mise run //deployment:tf destroy -- -refresh=false'
- name: Comment - name: Comment
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0 uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
with: with:
id: docs-pr-url
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
number: ${{ github.event.number }}
delete: true delete: true
body-include: '<!-- Docs PR URL -->'
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token - name: Generate a token
id: generate_token id: generate_token
if: ${{ inputs.skip != true }} if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with: with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-1
View File
@@ -13,4 +13,3 @@ jobs:
actions: read actions: read
contents: read contents: read
security-events: write security-events: write
secrets: inherit
+2 -2
View File
@@ -62,7 +62,7 @@ jobs:
ref: main ref: main
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -119,7 +119,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with: with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+12 -12
View File
@@ -19,11 +19,11 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0 - uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
with: with:
id: preview-status github-token: ${{ steps.token.outputs.token }}
token: ${{ steps.token.outputs.token }} message-id: 'preview-status'
body: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/' message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
remove-label: remove-label:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -48,16 +48,16 @@ jobs:
name: 'preview' name: 'preview'
}) })
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0 - uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
if: ${{ github.event.pull_request.head.repo.fork }} if: ${{ github.event.pull_request.head.repo.fork }}
with: with:
id: preview-status github-token: ${{ steps.token.outputs.token }}
token: ${{ steps.token.outputs.token }} message-id: 'preview-status'
body: 'PRs from forks cannot have preview environments.' message: 'PRs from forks cannot have preview environments.'
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0 - uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
id: preview-status github-token: ${{ steps.token.outputs.token }}
token: ${{ steps.token.outputs.token }} message-id: 'preview-status'
body: 'Preview environment has been removed.' message: 'Preview environment has been removed.'
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
+27 -23
View File
@@ -62,6 +62,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
defaults:
run:
working-directory: ./server
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0 uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
@@ -76,12 +79,12 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
- name: Run ci-unit - name: Run ci-unit
run: mise run //server:ci-unit run: mise run ci-unit
cli-unit-tests: cli-unit-tests:
name: Unit Test CLI name: Unit Test CLI
@@ -107,7 +110,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -138,7 +141,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -182,7 +185,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -220,7 +223,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -248,7 +251,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -298,7 +301,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -331,7 +334,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -377,7 +380,7 @@ jobs:
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup packages - name: Setup packages
run: pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && pnpm --filter @immich/sdk --filter @immich/cli build run: pnpm --filter "@immich/*" install --frozen-lockfile && pnpm --filter "@immich/*" build
- name: Run setup web - name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
@@ -550,7 +553,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -587,7 +590,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -618,7 +621,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
@@ -669,12 +672,13 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies - name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Run API generation - name: Run API generation
run: mise //:open-api run: mise //:open-api
working-directory: open-api working-directory: open-api
@@ -713,6 +717,9 @@ jobs:
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
ports: ports:
- 5432:5432 - 5432:5432
defaults:
run:
working-directory: ./server
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0 uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
@@ -727,28 +734,25 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies - name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build plugins
run: mise //:plugins
- name: Build the app - name: Build the app
run: mise //server:build run: pnpm build
- name: Run existing migrations - name: Run existing migrations
run: pnpm --filter immich migrations:run run: pnpm migrations:run
- name: Test npm run schema:reset command works - name: Test npm run schema:reset command works
run: pnpm --filter immich schema:reset run: pnpm schema:reset
- name: Generate new migrations - name: Generate new migrations
continue-on-error: true continue-on-error: true
run: pnpm --filter migrations:generate src/TestMigration run: pnpm migrations:generate src/TestMigration
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
@@ -764,7 +768,7 @@ jobs:
run: | run: |
echo "ERROR: Generated migration files not up to date!" echo "ERROR: Generated migration files not up to date!"
echo "Changed files: ${CHANGED_FILES}" echo "Changed files: ${CHANGED_FILES}"
cat ./server/src/*-TestMigration.ts cat ./src/*-TestMigration.ts
exit 1 exit 1
- name: Run SQL generation - name: Run SQL generation
+1 -1
View File
@@ -74,7 +74,7 @@ services:
- ${UPLOAD_LOCATION}/photos:/data - ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store - pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugin-core:/build/plugins/immich-plugin-core - ../packages/plugins:/build/corePlugin
env_file: env_file:
- .env - .env
environment: environment:
+2 -2
View File
@@ -5,7 +5,7 @@ After making any changes in the `server/src/schema`, a database migration need t
1. Run the command 1. Run the command
```bash ```bash
mise //server:migrations generate <migration-name> pnpm run migrations:generate <migration-name>
``` ```
2. Check if the migration file makes sense. 2. Check if the migration file makes sense.
@@ -18,7 +18,7 @@ The server will automatically detect `*.ts` file changes and restart. Part of th
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run: If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
```bash ```bash
mise //server:migrations revert pnpm run migrations:revert
``` ```
This command rolls back the latest migration and brings the database schema back to its previous state. This command rolls back the latest migration and brings the database schema back to its previous state.
+30 -19
View File
@@ -252,33 +252,44 @@ To connect the mobile app to your Dev Container:
The Dev Container supports multiple ways to run tests: The Dev Container supports multiple ways to run tests:
#### Using Mise Commands (Recommended)
```bash ```bash
# Server # Run tests for specific components
mise //server:test # unit tests mise run checklist # in `server/`, `web/`, `packages/cli`
mise //server:test-medium # medium / integration tests
# Web
mise //web:test # unit tests
# E2E
mise //e2e:test # API tests
mise //e2e:test-web # web UI tests (Playwright)
# Run all checks for a component
mise //server:checklist
mise //web:checklist
``` ```
### Additional Commands #### Using PNPM Directly
```bash
# Server tests
cd /workspaces/immich/server
pnpm test # Run all tests
pnpm run test:medium # Medium tests (integration tests)
pnpm run test:watch # Watch mode
pnpm run test:cov # Coverage report
# Web tests
cd /workspaces/immich/web
pnpm test # Run all tests
pnpm run test:watch # Watch mode
# E2E tests
cd /workspaces/immich/e2e
pnpm run test # Run API tests
pnpm run test:web # Run web UI tests
```
### Additional Make Commands
```bash ```bash
# API generation # API generation
mise //:open-api # Generate OpenAPI specs make open-api # Generate OpenAPI specs
mise //:open-api-typescript # Generate TypeScript SDK make open-api-typescript # Generate TypeScript SDK
mise //:open-api-dart # Generate Dart SDK make open-api-dart # Generate Dart SDK
# Database # Database
mise //server:sql # Sync database schema mise sql # Sync database schema
``` ```
### Debugging ### Debugging
+13 -32
View File
@@ -8,42 +8,34 @@ When contributing code through a pull request, please check the following:
## Web Checks ## Web Checks
- [ ] `mise //web:lint` (linting via ESLint) - [ ] `pnpm run lint` (linting via ESLint)
- [ ] `mise //web:format` (formatting via Prettier) - [ ] `pnpm run format` (formatting via Prettier)
- [ ] `mise //web:check-svelte` (type checking via SvelteKit) - [ ] `pnpm run check:svelte` (Type checking via SvelteKit)
- [ ] `mise //web:check-typescript` (type checking via `tsc`) - [ ] `pnpm run check:typescript` (check typescript)
- [ ] `mise //web:test` (unit tests) - [ ] `pnpm test` (unit tests)
:::tip AIO :::tip AIO
Run all web checks with `mise //web:checklist` Run all web checks with `pnpm run check:all`
:::
:::tip Auto Fix
Use `mise //web:lint-fix` and `mise //web:format-fix` to automatically correct some issues.
::: :::
## Documentation ## Documentation
- [ ] `mise //docs:format` (formatting via Prettier) - [ ] `pnpm run format` (formatting via Prettier)
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation. - [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
:::tip Auto Fix
Use `mise //docs:format-fix` to automatically fix formatting.
:::
## Server Checks ## Server Checks
- [ ] `mise //server:lint` (linting via ESLint) - [ ] `pnpm run lint` (linting via ESLint)
- [ ] `mise //server:format` (formatting via Prettier) - [ ] `pnpm run format` (formatting via Prettier)
- [ ] `mise //server:check` (type checking via `tsc`) - [ ] `pnpm run check` (Type checking via `tsc`)
- [ ] `mise //server:test` (unit tests) - [ ] `pnpm test` (unit tests)
:::tip AIO :::tip AIO
Run all server checks with `mise //server:checklist` Run all server checks with `pnpm run check:all`
::: :::
:::tip Auto Fix :::tip Auto Fix
Use `mise //server:lint-fix` and `mise //server:format-fix` to automatically correct some issues. You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`.
::: :::
## Mobile Checklist ## Mobile Checklist
@@ -61,17 +53,6 @@ Run all these commands at once with `mise //mobile:checklist`
You can use `mise //mobile:lint-fix` to potentially correct some issues automatically for `mise //mobile:lint`. You can use `mise //mobile:lint-fix` to potentially correct some issues automatically for `mise //mobile:lint`.
::: :::
## Machine Learning Checklist
- [ ] `mise //machine-learning:lint` (linting via ruff)
- [ ] `mise //machine-learning:format` (formatting via ruff)
- [ ] `mise //machine-learning:check` (type checking via mypy)
- [ ] `mise //machine-learning:test` (unit tests via pytest)
:::tip AIO
Run all machine learning checks with `mise //machine-learning:checklist`
:::
## OpenAPI ## OpenAPI
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details. The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details.
+17 -18
View File
@@ -32,10 +32,6 @@ This environment includes the services below. Additional details are available i
All the services are packaged to run as with single Docker Compose command. All the services are packaged to run as with single Docker Compose command.
:::tip mise
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
:::
### Server and web apps ### Server and web apps
1. Clone the project repo. 1. Clone the project repo.
@@ -60,23 +56,22 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
#### Connect web to a remote backend #### Connect web to a remote backend
If you only want to do web development connected to an existing, remote backend, run from the repo root: If you only want to do web development connected to an existing, remote backend, follow these steps:
1. Build the Immich SDK - `pnpm --filter @immich/sdk install && pnpm --filter @immich/sdk build`
2. Enter the web directory - `cd web/`
3. Install web dependencies - `pnpm i`
4. Start the web development server
```bash ```bash
IMMICH_SERVER_URL=https://demo.immich.app/ mise //web:start IMMICH_SERVER_URL=https://demo.immich.app/ pnpm run dev
```
This will install all dependencies (including the SDK) and start the dev server in one step. To connect to the hosted demo server specifically, use the shorthand:
```bash
mise //web:start-demo
``` ```
If you're using PowerShell on Windows you may need to set the env var separately like so: If you're using PowerShell on Windows you may need to set the env var separately like so:
```powershell ```powershell
$env:IMMICH_SERVER_URL = "https://demo.immich.app/" $env:IMMICH_SERVER_URL = "https://demo.immich.app/"
mise //web:start pnpm run dev
``` ```
#### `@immich/ui` #### `@immich/ui`
@@ -95,16 +90,20 @@ To see local changes to `@immich/ui` in Immich, do the following:
#### Setup #### Setup
1. Run `mise //mobile:install` to install Flutter dependencies. 1. [Install mise](https://mise.jdx.dev/installing-mise.html).
2. Run `mise //mobile:translation` to generate the translation file. 2. Change to the immich (root) directory and trust the mise config with `mise trust`.
3. Change to the `mobile/` directory and run `flutter run` to start the app. 3. Install tools with mise: `mise install`.
4. Change to the `mobile/` directory.
5. Run `flutter pub get` to install the dependencies.
6. Run `make translation` to generate the translation file.
7. Run `flutter run` to start the app.
#### Translation #### Translation
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then run: To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then, from the `mobile/` directory, run
```bash ```bash
mise //mobile:translation make translation
``` ```
The mobile app asks you what backend to connect to. You can utilize the demo backend (https://demo.immich.app/) if you don't need to change server code or upload photos. Alternatively, you can run the server yourself per the instructions above. The mobile app asks you what backend to connect to. You can utilize the demo backend (https://demo.immich.app/) if you don't need to change server code or upload photos. Alternatively, you can run the server yourself per the instructions above.
+4 -3
View File
@@ -4,8 +4,8 @@
### Unit tests ### Unit tests
Unit tests are run with `mise //server:test`. Unit are run by calling `pnpm run test` from the `server/` directory.
You need to run `mise //server:install` before _once_. You need to run `pnpm install` (in `server/`) before _once_.
### End to end tests ### End to end tests
@@ -17,7 +17,8 @@ make e2e
Before you can run the tests, you need to run the following commands _once_: Before you can run the tests, you need to run the following commands _once_:
- `mise //e2e:ci-setup` (installs e2e, SDK, and CLI dependencies) - `pnpm install`
- `pnpm --filter "@immich/*" build`
- `mise //:open-api` - `mise //:open-api`
Once the test environment is running, the e2e tests can be run via: Once the test environment is running, the e2e tests can be run via:
+1 -1
View File
@@ -3,7 +3,7 @@ run = "pnpm install --filter documentation --frozen-lockfile"
[tasks.start] [tasks.start]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "docusaurus start --port 3005" run = "docusaurus --port 3005"
[tasks.build] [tasks.build]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
+8 -26
View File
@@ -22,12 +22,13 @@
"add_birthday": "Add a birthday", "add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint", "add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern", "add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location", "add_location": "Add location",
"add_more_users": "Add more users", "add_more_users": "Add more users",
"add_partner": "Add partner", "add_partner": "Add partner",
"add_path": "Add path", "add_path": "Add path",
"add_photos": "Add photos", "add_photos": "Add photos",
"add_step": "Add step",
"add_tag": "Add tag", "add_tag": "Add tag",
"add_to": "Add to…", "add_to": "Add to…",
"add_to_album": "Add to album", "add_to_album": "Add to album",
@@ -41,6 +42,7 @@
"add_to_shared_album": "Add to shared album", "add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack", "add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL", "add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive", "added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites", "added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites", "added_to_favorites_count": "Added {count, number} to favorites",
@@ -731,7 +733,6 @@
"cannot_update_the_description": "Cannot update the description", "cannot_update_the_description": "Cannot update the description",
"cast": "Cast", "cast": "Cast",
"cast_description": "Configure available cast destinations", "cast_description": "Configure available cast destinations",
"change": "Change",
"change_date": "Change date", "change_date": "Change date",
"change_description": "Change description", "change_description": "Change description",
"change_display_order": "Change display order", "change_display_order": "Change display order",
@@ -760,7 +761,6 @@
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "Check Logs", "check_logs": "Check Logs",
"checksum": "Checksum", "checksum": "Checksum",
"choose": "Choose",
"choose_matching_people_to_merge": "Choose matching people to merge", "choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City", "city": "City",
"cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?", "cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
@@ -778,7 +778,6 @@
"clear": "Clear", "clear": "Clear",
"clear_all": "Clear all", "clear_all": "Clear all",
"clear_all_recent_searches": "Clear all recent searches", "clear_all_recent_searches": "Clear all recent searches",
"clear_failed_count": "Clear failed ({count})",
"clear_file_cache": "Clear File Cache", "clear_file_cache": "Clear File Cache",
"clear_message": "Clear message", "clear_message": "Clear message",
"clear_value": "Clear value", "clear_value": "Clear value",
@@ -810,7 +809,6 @@
"comments_are_disabled": "Comments are disabled", "comments_are_disabled": "Comments are disabled",
"common_create_new_album": "Create new album", "common_create_new_album": "Create new album",
"completed": "Completed", "completed": "Completed",
"configuration": "Configuration",
"confirm": "Confirm", "confirm": "Confirm",
"confirm_admin_password": "Confirm Admin Password", "confirm_admin_password": "Confirm Admin Password",
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
@@ -825,7 +823,6 @@
"contain": "Contain", "contain": "Contain",
"context": "Context", "context": "Context",
"continue": "Continue", "continue": "Continue",
"control_bottom_app_bar_add_tags": "Add Tags",
"control_bottom_app_bar_create_new_album": "Create new album", "control_bottom_app_bar_create_new_album": "Create new album",
"control_bottom_app_bar_delete_from_immich": "Delete from Immich", "control_bottom_app_bar_delete_from_immich": "Delete from Immich",
"control_bottom_app_bar_delete_from_local": "Delete from device", "control_bottom_app_bar_delete_from_local": "Delete from device",
@@ -897,7 +894,6 @@
"date_of_birth": "Date of birth", "date_of_birth": "Date of birth",
"date_of_birth_saved": "Date of birth saved successfully", "date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range", "date_range": "Date range",
"date_time_original": "Date/Time Original",
"day": "Day", "day": "Day",
"days": "Days", "days": "Days",
"deduplicate_all": "Deduplicate All", "deduplicate_all": "Deduplicate All",
@@ -1078,7 +1074,6 @@
"failed_to_remove_product_key": "Failed to remove product key", "failed_to_remove_product_key": "Failed to remove product key",
"failed_to_reset_pin_code": "Failed to reset PIN code", "failed_to_reset_pin_code": "Failed to reset PIN code",
"failed_to_stack_assets": "Failed to stack assets", "failed_to_stack_assets": "Failed to stack assets",
"failed_to_tag_assets": "Failed to tag assets",
"failed_to_unstack_assets": "Failed to un-stack assets", "failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status", "failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password", "incorrect_email_or_password": "Incorrect email or password",
@@ -1198,13 +1193,11 @@
"export_as_json": "Export as JSON", "export_as_json": "Export as JSON",
"export_database": "Export Database", "export_database": "Export Database",
"export_database_description": "Export the SQLite database", "export_database_description": "Export the SQLite database",
"exposure_time": "Exposure Time",
"extension": "Extension", "extension": "Extension",
"external": "External", "external": "External",
"external_libraries": "External Libraries", "external_libraries": "External Libraries",
"external_network": "External network", "external_network": "External network",
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"f_number": "F-Number",
"face_unassigned": "Unassigned", "face_unassigned": "Unassigned",
"failed": "Failed", "failed": "Failed",
"failed_count": "Failed: {count}", "failed_count": "Failed: {count}",
@@ -1222,6 +1215,7 @@
"features_setting_description": "Manage the app features", "features_setting_description": "Manage the app features",
"file_name_or_extension": "File name or extension", "file_name_or_extension": "File name or extension",
"file_name_text": "File name", "file_name_text": "File name",
"file_name_with_value": "File name: {file_name}",
"file_size": "File size", "file_size": "File size",
"filename": "Filename", "filename": "Filename",
"filetype": "Filetype", "filetype": "Filetype",
@@ -1234,7 +1228,6 @@
"find_them_fast": "Find them fast by name with search", "find_them_fast": "Find them fast by name with search",
"first": "First", "first": "First",
"fix_incorrect_match": "Fix incorrect match", "fix_incorrect_match": "Fix incorrect match",
"focal_length": "Focal Length",
"folder": "Folder", "folder": "Folder",
"folder_not_found": "Folder not found", "folder_not_found": "Folder not found",
"folders": "Folders", "folders": "Folders",
@@ -1355,7 +1348,6 @@
"ios_debug_info_no_sync_yet": "No background sync job has run yet", "ios_debug_info_no_sync_yet": "No background sync job has run yet",
"ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}", "ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}",
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}", "ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"iso": "ISO",
"items_count": "{count, plural, one {# item} other {# items}}", "items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs", "jobs": "Jobs",
"json_editor": "JSON editor", "json_editor": "JSON editor",
@@ -1588,7 +1580,6 @@
"mobile_app": "Mobile App", "mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options", "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model", "model": "Model",
"modify_date": "Modify Date",
"month": "Month", "month": "Month",
"more": "More", "more": "More",
"motion": "Motion", "motion": "Motion",
@@ -1637,6 +1628,7 @@
"next": "Next", "next": "Next",
"next_memory": "Next memory", "next_memory": "Next memory",
"no": "No", "no": "No",
"no_actions_added": "No actions added yet",
"no_albums_found": "No albums found", "no_albums_found": "No albums found",
"no_albums_message": "Create an album to organize your photos and videos", "no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.", "no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
@@ -1653,6 +1645,7 @@
"no_exif_info_available": "No exif info available", "no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.", "no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos", "no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos", "no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum", "no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set", "no_location_set": "No location set",
@@ -1665,7 +1658,6 @@
"no_results": "No results", "no_results": "No results",
"no_results_description": "Try a synonym or more general keyword", "no_results_description": "Try a synonym or more general keyword",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network", "no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"no_steps": "No steps added yet",
"no_uploads_in_progress": "No uploads in progress", "no_uploads_in_progress": "No uploads in progress",
"none": "None", "none": "None",
"not_allowed": "Not allowed", "not_allowed": "Not allowed",
@@ -1711,7 +1703,6 @@
"organize_into_albums": "Organize into albums", "organize_into_albums": "Organize into albums",
"organize_into_albums_description": "Put existing photos into albums using current sync settings", "organize_into_albums_description": "Put existing photos into albums using current sync settings",
"organize_your_library": "Organize your library", "organize_your_library": "Organize your library",
"orientation": "Orientation",
"original": "original", "original": "original",
"other": "Other", "other": "Other",
"other_devices": "Other devices", "other_devices": "Other devices",
@@ -1803,8 +1794,6 @@
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.", "play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
"play_transcoded_video": "Play transcoded video", "play_transcoded_video": "Play transcoded video",
"please_auth_to_access": "Please authenticate to access", "please_auth_to_access": "Please authenticate to access",
"plugin_method_filter_type": "Filter",
"plugin_method_filter_type_description": "This method can filter events and conditionally prevent subsequent steps from running",
"port": "Port", "port": "Port",
"preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_subtitle": "Manage the app's preferences",
"preferences_settings_title": "Preferences", "preferences_settings_title": "Preferences",
@@ -1826,7 +1815,6 @@
"profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.", "profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.",
"profile_image_of_user": "Profile image of {user}", "profile_image_of_user": "Profile image of {user}",
"profile_picture_set": "Profile picture set.", "profile_picture_set": "Profile picture set.",
"projection_type": "Projection Type",
"public_album": "Public album", "public_album": "Public album",
"public_share": "Public Share", "public_share": "Public Share",
"purchase_account_info": "Supporter", "purchase_account_info": "Supporter",
@@ -2196,9 +2184,7 @@
"show_in_timeline": "Show in timeline", "show_in_timeline": "Show in timeline",
"show_in_timeline_setting_description": "Show photos and videos from this user in your timeline", "show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
"show_keyboard_shortcuts": "Show keyboard shortcuts", "show_keyboard_shortcuts": "Show keyboard shortcuts",
"show_less": "Show less",
"show_metadata": "Show metadata", "show_metadata": "Show metadata",
"show_more_fields": "{count, plural, one {Show # more field} other {Show # more fields}}",
"show_or_hide_info": "Show or hide info", "show_or_hide_info": "Show or hide info",
"show_password": "Show password", "show_password": "Show password",
"show_person_options": "Show person options", "show_person_options": "Show person options",
@@ -2250,10 +2236,6 @@
"start_date_before_end_date": "Start date must be before end date", "start_date_before_end_date": "Start date must be before end date",
"state": "State", "state": "State",
"status": "Status", "status": "Status",
"step_delete": "Delete step",
"step_delete_confirm": "Are you sure you want to delete this step?",
"step_details": "Step details",
"steps": "Steps",
"stop_casting": "Stop casting", "stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo", "stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?", "stop_photo_sharing": "Stop sharing your photos?",
@@ -2347,7 +2329,7 @@
"trash_page_title": "Trash ({count})", "trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger", "trigger": "Trigger",
"trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded": "Asset Uploaded",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kicks off the workflow", "trigger_description": "An event that kicks off the workflow",
"trigger_person_recognized": "Person Recognized", "trigger_person_recognized": "Person Recognized",
@@ -2387,6 +2369,7 @@
"unsupported_field_type": "Unsupported field type", "unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.", "unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged", "untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next", "up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:", "update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated", "updated_at": "Updated",
@@ -2478,7 +2461,6 @@
"welcome_to_immich": "Welcome to Immich", "welcome_to_immich": "Welcome to Immich",
"width": "Width", "width": "Width",
"wifi_name": "Wi-Fi Name", "wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?", "workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted", "workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description", "workflow_description": "Workflow description",
+1 -19
View File
@@ -2,7 +2,7 @@ experimental_monorepo_root = true
[monorepo] [monorepo]
config_roots = [ config_roots = [
"packages/plugin-core", "packages/plugins",
"server", "server",
"packages/cli", "packages/cli",
"deployment", "deployment",
@@ -22,22 +22,12 @@ terragrunt = "1.0.3"
opentofu = "1.11.6" opentofu = "1.11.6"
java = "21.0.2" java = "21.0.2"
"npm:oazapfts" = "7.5.0" "npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.6.0"
[tools."github:CQLabs/homebrew-dcm"] [tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0" version = "1.37.0"
bin = "dcm" bin = "dcm"
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true" postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
[tools."github:CQLabs/homebrew-dcm".platforms]
linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
[tools."github:jellyfin/jellyfin-ffmpeg"] [tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6" version = "7.1.3-6"
@@ -51,12 +41,6 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
experimental = true experimental = true
pin = true pin = true
[tasks.plugins]
run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build"
]
[tasks.open-api-typescript] [tasks.open-api-typescript]
run = [ run = [
"oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts", "oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts",
@@ -71,8 +55,6 @@ run = "bash ./bin/generate-dart-sdk.sh"
[tasks.open-api] [tasks.open-api]
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true } env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [ run = [
{ task = "//:plugins" },
{ task = "//server:build" },
{ task = "//server:install" }, { task = "//server:install" },
{ task = "//server:build" }, { task = "//server:build" },
{ task = "//server:sync-open-api" }, { task = "//server:sync-open-api" },
-7
View File
@@ -89,13 +89,6 @@ flutter {
} }
dependencies { dependencies {
constraints {
implementation("androidx.glance:glance-appwidget") {
version { strictly libs.versions.glance.get() }
because 'home_widget requests 1.+ which can resolve to pre-releases incompatible with our compileSdk/AGP'
}
}
implementation libs.okhttp implementation libs.okhttp
implementation libs.cronet.embedded implementation libs.cronet.embedded
implementation libs.media3.datasource.okhttp implementation libs.media3.datasource.okhttp
@@ -23,8 +23,6 @@ import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal) private class RemoteRequest(val cancellationSignal: CancellationSignal)
class RemoteImagesImpl(context: Context) : RemoteImageApi { class RemoteImagesImpl(context: Context) : RemoteImageApi {
@@ -230,6 +228,7 @@ private class CronetImageFetcher : ImageFetcher {
private val onComplete: () -> Unit, private val onComplete: () -> Unit,
) : UrlRequest.Callback() { ) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null private var buffer: NativeByteBuffer? = null
private var wrapped: ByteBuffer? = null
private var error: Exception? = null private var error: Exception? = null
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) { override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
@@ -243,16 +242,15 @@ private class CronetImageFetcher : ImageFetcher {
} }
try { try {
// Content-Length is a size hint only. With Content-Encoding (gzip/br/...),
// Cronet auto-decompresses and writes decompressed bytes to our buffer, which
// may exceed the wire/compressed Content-Length. Always use the growable
// buffer path so we can't overflow.
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0 val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
// Cap the up-front alloc: Content-Length is untrusted and can be huge or near if (contentLength > 0) {
// Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over. buffer = NativeByteBuffer(contentLength + 1)
val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
buffer = NativeByteBuffer(initialSize) request.read(wrapped)
request.read(buffer!!.wrapRemaining()) } else {
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
request.read(buffer!!.wrapRemaining())
}
} catch (e: Exception) { } catch (e: Exception) {
error = e error = e
return request.cancel() return request.cancel()
@@ -265,14 +263,14 @@ private class CronetImageFetcher : ImageFetcher {
byteBuffer: ByteBuffer byteBuffer: ByteBuffer
) { ) {
try { try {
// Always pass a fresh wrap so byteBuffer.position() represents only the val buf = if (wrapped == null) {
// bytes Cronet wrote in this iteration. Reusing the caller-supplied buffer!!.run {
// ByteBuffer breaks advance(): Cronet's position keeps accumulating advance(byteBuffer.position())
// across reads, which would double-count previous iterations' bytes. ensureHeadroom()
val buf = buffer!!.run { wrapRemaining()
advance(byteBuffer.position()) }
ensureHeadroom() } else {
wrapRemaining() wrapped
} }
request.read(buf) request.read(buf)
} catch (e: Exception) { } catch (e: Exception) {
@@ -282,6 +280,7 @@ private class CronetImageFetcher : ImageFetcher {
} }
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
wrapped?.let { buffer!!.advance(it.position()) }
onSuccess(buffer!!) onSuccess(buffer!!)
onComplete() onComplete()
} }
+1 -1
View File
@@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
var domainAlbum = PlatformAlbum( var domainAlbum = PlatformAlbum(
id: album.localIdentifier, id: album.localIdentifier,
name: album.localizedTitle ?? album.localIdentifier, name: album.localizedTitle!,
updatedAt: nil, updatedAt: nil,
isCloud: isCloud, isCloud: isCloud,
assetCount: Int64(assets.count) assetCount: Int64(assets.count)
-4
View File
@@ -18,7 +18,3 @@ enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly } enum AssetKeepType { none, photosOnly, videosOnly }
enum AssetDateAggregation { start, end } enum AssetDateAggregation { start, end }
enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
@@ -1,26 +0,0 @@
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class AlbumConfig {
final AlbumSortMode sortMode;
final bool isReverse;
final bool isGrid;
const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false});
AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig(
sortMode: sortMode ?? this.sortMode,
isReverse: isReverse ?? this.isReverse,
isGrid: isGrid ?? this.isGrid,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid);
@override
int get hashCode => Object.hash(sortMode, isReverse, isGrid);
@override
String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)';
}
@@ -1,9 +1,6 @@
import 'package:immich_mobile/domain/models/config/album_config.dart';
import 'package:immich_mobile/domain/models/config/backup_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart'; import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart'; import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart'; import 'package:immich_mobile/domain/models/config/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart'; import 'package:immich_mobile/domain/models/config/viewer_config.dart';
@@ -15,9 +12,6 @@ class AppConfig {
final TimelineConfig timeline; final TimelineConfig timeline;
final ImageConfig image; final ImageConfig image;
final ViewerConfig viewer; final ViewerConfig viewer;
final SlideshowConfig slideshow;
final AlbumConfig album;
final BackupConfig backup;
const AppConfig({ const AppConfig({
this.theme = const .new(), this.theme = const .new(),
@@ -26,9 +20,6 @@ class AppConfig {
this.timeline = const .new(), this.timeline = const .new(),
this.image = const .new(), this.image = const .new(),
this.viewer = const .new(), this.viewer = const .new(),
this.slideshow = const .new(),
this.album = const .new(),
this.backup = const .new(),
}); });
AppConfig copyWith({ AppConfig copyWith({
@@ -38,9 +29,6 @@ class AppConfig {
TimelineConfig? timeline, TimelineConfig? timeline,
ImageConfig? image, ImageConfig? image,
ViewerConfig? viewer, ViewerConfig? viewer,
SlideshowConfig? slideshow,
AlbumConfig? album,
BackupConfig? backup,
}) => .new( }) => .new(
theme: theme ?? this.theme, theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup, cleanup: cleanup ?? this.cleanup,
@@ -48,9 +36,6 @@ class AppConfig {
timeline: timeline ?? this.timeline, timeline: timeline ?? this.timeline,
image: image ?? this.image, image: image ?? this.image,
viewer: viewer ?? this.viewer, viewer: viewer ?? this.viewer,
slideshow: slideshow ?? this.slideshow,
album: album ?? this.album,
backup: backup ?? this.backup,
); );
@override @override
@@ -62,15 +47,12 @@ class AppConfig {
other.map == map && other.map == map &&
other.timeline == timeline && other.timeline == timeline &&
other.image == image && other.image == image &&
other.viewer == viewer && other.viewer == viewer);
other.slideshow == slideshow &&
other.album == album &&
other.backup == backup);
@override @override
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup); int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer);
@override @override
String toString() => String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)'; 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)';
} }
@@ -1,52 +0,0 @@
class BackupConfig {
final bool enabled;
final bool useCellularForVideos;
final bool useCellularForPhotos;
final bool requireCharging;
final int triggerDelay;
final bool syncAlbums;
const BackupConfig({
this.enabled = false,
this.useCellularForVideos = false,
this.useCellularForPhotos = false,
this.requireCharging = false,
this.triggerDelay = 30,
this.syncAlbums = false,
});
BackupConfig copyWith({
bool? enabled,
bool? useCellularForVideos,
bool? useCellularForPhotos,
bool? requireCharging,
int? triggerDelay,
bool? syncAlbums,
}) => BackupConfig(
enabled: enabled ?? this.enabled,
useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos,
useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos,
requireCharging: requireCharging ?? this.requireCharging,
triggerDelay: triggerDelay ?? this.triggerDelay,
syncAlbums: syncAlbums ?? this.syncAlbums,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is BackupConfig &&
other.enabled == enabled &&
other.useCellularForVideos == useCellularForVideos &&
other.useCellularForPhotos == useCellularForPhotos &&
other.requireCharging == requireCharging &&
other.triggerDelay == triggerDelay &&
other.syncAlbums == syncAlbums);
@override
int get hashCode =>
Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums);
@override
String toString() =>
'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)';
}
@@ -1,54 +0,0 @@
import 'package:flutter/foundation.dart';
class NetworkConfig {
final bool autoEndpointSwitching;
final String? preferredWifiName;
final String? localEndpoint;
final List<String> externalEndpointList;
final Map<String, String> customHeaders;
const NetworkConfig({
this.autoEndpointSwitching = false,
this.preferredWifiName,
this.localEndpoint,
this.externalEndpointList = const [],
this.customHeaders = const {},
});
NetworkConfig copyWith({
bool? autoEndpointSwitching,
String? preferredWifiName,
String? localEndpoint,
List<String>? externalEndpointList,
Map<String, String>? customHeaders,
}) => NetworkConfig(
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
localEndpoint: localEndpoint ?? this.localEndpoint,
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
customHeaders: customHeaders ?? this.customHeaders,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is NetworkConfig &&
other.autoEndpointSwitching == autoEndpointSwitching &&
other.preferredWifiName == preferredWifiName &&
other.localEndpoint == localEndpoint &&
listEquals(other.externalEndpointList, externalEndpointList) &&
mapEquals(other.customHeaders, customHeaders));
@override
int get hashCode => Object.hash(
autoEndpointSwitching,
preferredWifiName,
localEndpoint,
Object.hashAll(externalEndpointList),
Object.hashAllUnordered(customHeaders.entries.map((e) => Object.hash(e.key, e.value))),
);
@override
String toString() =>
'NetworkConfig(autoEndpointSwitching: $autoEndpointSwitching, preferredWifiName: $preferredWifiName, localEndpoint: $localEndpoint, externalEndpointList: $externalEndpointList, customHeaders: $customHeaders)';
}
@@ -1,48 +0,0 @@
import 'package:immich_mobile/constants/enums.dart';
class SlideshowConfig {
final bool transition;
final bool repeat;
final int duration;
final SlideshowLook look;
final SlideshowDirection direction;
const SlideshowConfig({
this.transition = true,
this.repeat = true,
this.duration = 5,
this.look = SlideshowLook.contain,
this.direction = SlideshowDirection.forward,
});
SlideshowConfig copyWith({
bool? transition,
bool? repeat,
int? duration,
SlideshowLook? look,
SlideshowDirection? direction,
}) => SlideshowConfig(
transition: transition ?? this.transition,
repeat: repeat ?? this.repeat,
duration: duration ?? this.duration,
look: look ?? this.look,
direction: direction ?? this.direction,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SlideshowConfig &&
other.transition == transition &&
other.repeat == repeat &&
other.duration == duration &&
other.look == look &&
other.direction == direction);
@override
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
@override
String toString() =>
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
}
@@ -1,22 +1,18 @@
import 'package:immich_mobile/domain/models/config/network_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
class SystemConfig { class SystemConfig {
final LogLevel logLevel; final LogLevel logLevel;
final NetworkConfig network;
const SystemConfig({this.logLevel = .info, this.network = const .new()}); const SystemConfig({this.logLevel = .info});
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) => SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
@override @override
int get hashCode => Object.hash(logLevel, network); int get hashCode => logLevel.hashCode;
@override @override
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)'; String toString() => 'SystemConfig(logLevel: $logLevel)';
} }
+1 -90
View File
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/log.model.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/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataDomain<T extends Object> { enum MetadataDomain<T extends Object> {
appConfig<AppConfig>('config.app'), appConfig<AppConfig>('config.app'),
@@ -35,41 +34,6 @@ enum MetadataKey<T extends Object> {
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true), viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false), viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
// Network
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
networkExternalEndpointList<List<String>>(
.systemConfig,
'network.externalEndpointList',
[],
_ListCodec(_PrimitiveCodec.string),
),
networkCustomHeaders<Map<String, String>>(
.systemConfig,
'network.customHeaders',
{},
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
// Album
albumSortMode<AlbumSortMode>(
.appConfig,
'album.sortMode',
AlbumSortMode.mostRecent,
_EnumCodec(AlbumSortMode.values),
),
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
// Backup
backupEnabled<bool>(.appConfig, 'backup.enabled', false),
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false),
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false),
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false),
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30),
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false),
// Timeline // Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4), timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>( timelineGroupAssetsBy<GroupAssetsBy>(
@@ -100,19 +64,7 @@ enum MetadataKey<T extends Object> {
), ),
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)), cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1), cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false), cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false);
// Slideshow
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(
.appConfig,
'slideshow.direction',
SlideshowDirection.forward,
_EnumCodec(SlideshowDirection.values),
);
final MetadataDomain domain; final MetadataDomain domain;
final String name; final String name;
@@ -179,47 +131,6 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
DateTime? decode(String raw) => DateTime.tryParse(raw); DateTime? decode(String raw) => DateTime.tryParse(raw);
} }
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
final _MetadataCodec<K> _keyCodec;
final _MetadataCodec<V> _valueCodec;
const _MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V>? decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return null;
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return null;
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
if (k == null || v == null) {
return null;
}
result[k] = v;
}
return result;
} on FormatException {
return null;
}
}
}
final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> { final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
final _MetadataCodec<T> _elementCodec; final _MetadataCodec<T> _elementCodec;
+2 -1
View File
@@ -1,7 +1,8 @@
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
enum Setting<T> { enum Setting<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false); advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
enableBackup<bool>(StoreKey.enableBackup, false);
const Setting(this.storeKey, this.defaultValue); const Setting(this.storeKey, this.defaultValue);
+17 -14
View File
@@ -6,33 +6,36 @@ enum StoreKey<T> {
version<int>._(0), version<int>._(0),
currentUser<UserDto>._(2), currentUser<UserDto>._(2),
deviceId<String>._(4), deviceId<String>._(4),
backupRequireCharging<bool>._(7),
backupTriggerDelay<int>._(8),
serverUrl<String>._(10), serverUrl<String>._(10),
accessToken<String>._(11), accessToken<String>._(11),
serverEndpoint<String>._(12), serverEndpoint<String>._(12),
selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114), advancedTroubleshooting<bool>._(114),
selectedAlbumSortReverse<bool>._(123),
enableHapticFeedback<bool>._(126), enableHapticFeedback<bool>._(126),
customHeaders<String>._(127),
syncAlbums<bool>._(131),
// Auto endpoint switching
autoEndpointSwitching<bool>._(132),
preferredWifiName<String>._(133),
localEndpoint<String>._(134),
externalEndpointList<String>._(135),
manageLocalMediaAndroid<bool>._(137), manageLocalMediaAndroid<bool>._(137),
// Read-only Mode settings // Read-only Mode settings
readonlyModeEnabled<bool>._(138), readonlyModeEnabled<bool>._(138),
albumGridView<bool>._(140),
// Experimental stuff
enableBackup<bool>._(1003),
useWifiForUploadVideos<bool>._(1004),
useWifiForUploadPhotos<bool>._(1005),
syncMigrationStatus<String>._(1013), syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store // Legacy keys that have been migrated to the new metadata store
legacyBackupRequireCharging<bool>._(7),
legacyBackupTriggerDelay<int>._(8),
legacySyncAlbums<bool>._(131),
legacyEnableBackup<bool>._(1003),
legacyUseWifiForUploadVideos<bool>._(1004),
legacyUseWifiForUploadPhotos<bool>._(1005),
legacySelectedAlbumSortOrder<int>._(113),
legacySelectedAlbumSortReverse<bool>._(123),
legacyAlbumGridView<bool>._(140),
legacyAutoEndpointSwitching<bool>._(132),
legacyPreferredWifiName<String>._(133),
legacyLocalEndpoint<String>._(134),
legacyExternalEndpointList<String>._(135),
legacyCustomHeaders<String>._(127),
legacyLoopVideo<bool>._(117), legacyLoopVideo<bool>._(117),
legacyLoadOriginalVideo<bool>._(136), legacyLoadOriginalVideo<bool>._(136),
legacyAutoPlayVideo<bool>._(139), legacyAutoPlayVideo<bool>._(139),
@@ -11,14 +11,15 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
@@ -38,15 +39,16 @@ class BackgroundWorkerFgService {
Future<void> saveNotificationMessage(String title, String body) => Future<void> saveNotificationMessage(String title, String body) =>
_foregroundHostApi.saveNotificationMessage(title, body); _foregroundHostApi.saveNotificationMessage(title, body);
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) { Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
final backup = MetadataRepository.instance.appConfig.backup; BackgroundWorkerSettings(
return _foregroundHostApi.configure( minimumDelaySeconds:
BackgroundWorkerSettings( minimumDelaySeconds ??
minimumDelaySeconds: minimumDelaySeconds ?? backup.triggerDelay, Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
requiresCharging: requireCharging ?? backup.requireCharging, requiresCharging:
), requireCharging ??
); Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
} ),
);
Future<void> disable() => _foregroundHostApi.disable(); Future<void> disable() => _foregroundHostApi.disable();
} }
@@ -69,7 +71,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
BackgroundWorkerFlutterApi.setUp(this); BackgroundWorkerFlutterApi.setUp(this);
} }
bool get _isBackupEnabled => MetadataRepository.instance.appConfig.backup.enabled; bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
Future<void> init() async { Future<void> init() async {
try { try {
@@ -9,47 +9,12 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
/// Categorizes a heterogeneous asset selection into the candidates that can
/// be added to an album immediately (already on the server) and the local-only
/// candidates that must be uploaded first.
class AlbumAssetCandidates {
final List<String> remoteAssetIds;
final List<LocalAsset> localAssetsToUpload;
const AlbumAssetCandidates({required this.remoteAssetIds, required this.localAssetsToUpload});
}
class RemoteAlbumService { class RemoteAlbumService {
static final _logger = Logger('RemoteAlbumService');
final DriftRemoteAlbumRepository _repository; final DriftRemoteAlbumRepository _repository;
final DriftAlbumApiRepository _albumApiRepository; final DriftAlbumApiRepository _albumApiRepository;
final ForegroundUploadService _uploadService;
const RemoteAlbumService(this._repository, this._albumApiRepository, this._uploadService); const RemoteAlbumService(this._repository, this._albumApiRepository);
/// Categorizes a heterogeneous asset selection into already-on-server IDs
/// and local assets that still need to be uploaded.
static AlbumAssetCandidates categorizeCandidates(Iterable<BaseAsset> assets) {
final remoteIds = <String>[];
final localToUpload = <LocalAsset>[];
for (final asset in assets) {
if (asset is RemoteAsset) {
remoteIds.add(asset.id);
} else if (asset is LocalAsset) {
final remoteId = asset.remoteId;
if (remoteId != null) {
remoteIds.add(remoteId);
} else {
localToUpload.add(asset);
}
}
}
return AlbumAssetCandidates(remoteAssetIds: remoteIds, localAssetsToUpload: localToUpload);
}
Stream<RemoteAlbum?> watchAlbum(String albumId) { Stream<RemoteAlbum?> watchAlbum(String albumId) {
return _repository.watchAlbum(albumId); return _repository.watchAlbum(albumId);
@@ -183,122 +148,6 @@ class RemoteAlbumService {
return album.added.length; return album.added.length;
} }
/// !TODO The name here is not clear as we have addAssets method above,
/// which is only add remote assets to album, for the next PR, we will allow
/// adding local assets from album from the timeline as well with this flow.
/// So saving that for the next refactor
Future<int> addAssetsToAlbum({
required String albumId,
required UserDto uploader,
required AlbumAssetCandidates candidates,
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
}) async {
int addedCount = 0;
if (candidates.remoteAssetIds.isNotEmpty) {
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
}
if (candidates.localAssetsToUpload.isNotEmpty) {
addedCount += await _uploadAndAddLocals(albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks);
}
return addedCount;
}
/// Creates an album, seeding it with already-remote asset IDs, then uploads
/// local-only assets and links each one as it finishes.
Future<RemoteAlbum> createAlbumWithAssets({
required String title,
required UserDto owner,
String? description,
AlbumAssetCandidates candidates = const AlbumAssetCandidates(remoteAssetIds: [], localAssetsToUpload: []),
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
}) async {
final album = await createAlbum(
title: title,
owner: owner,
description: description,
assetIds: candidates.remoteAssetIds,
);
if (candidates.localAssetsToUpload.isNotEmpty) {
await _uploadAndAddLocals(album.id, owner, candidates.localAssetsToUpload, uploadCallbacks);
}
return album;
}
Future<int> _uploadAndAddLocals(
String albumId,
UserDto uploader,
List<LocalAsset> localAssets,
UploadCallbacks userCallbacks,
) async {
int addedCount = 0;
final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a};
final wrappedCallbacks = UploadCallbacks(
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
'Upload progress callback failed for $localId',
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
),
onICloudProgress: (localId, progress) => _runUploadCallback(
'iCloud progress callback failed for $localId',
() => userCallbacks.onICloudProgress?.call(localId, progress),
),
onError: (localId, errorMessage) => _runUploadCallback(
'Upload error callback failed for $localId',
() => userCallbacks.onError?.call(localId, errorMessage),
),
onSuccess: (localId, remoteId) {
_runUploadCallback(
'Upload success callback failed for $localId',
() => userCallbacks.onSuccess?.call(localId, remoteId),
);
final source = localById[localId];
if (source == null) {
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
return;
}
pendingAdds.add(
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) {
addedCount += added;
})
.catchError((Object error, StackTrace stack) {
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
}),
);
},
);
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds);
return addedCount;
}
void _runUploadCallback(String message, void Function() callback) {
try {
callback();
} catch (error, stack) {
_logger.warning(message, error, stack);
}
}
/// Links a freshly-uploaded asset to an album, ensuring the local DB
/// reflects the change without waiting for the next sync. We call the API
/// (server is the source of truth), then upsert a placeholder
/// `remote_asset_entity` row from the local source so the FK-protected
/// junction insert succeeds. Sync overwrites the placeholder later with
/// the authoritative server data.
Future<int> _linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async {
final result = await _albumApiRepository.addAssets(albumId, [remoteId]);
if (result.added.isEmpty) {
return 0;
}
await _repository.upsertRemoteAssetStub(remoteId: remoteId, ownerId: uploader.id, source: source);
await _repository.addAssets(albumId, result.added);
return result.added.length;
}
Future<void> deleteAlbum(String albumId) async { Future<void> deleteAlbum(String albumId) async {
await _albumApiRepository.deleteAlbum(albumId); await _albumApiRepository.deleteAlbum(albumId);
@@ -1,31 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
final tagServiceProvider = Provider<TagService>((ref) => TagService(ref.watch(tagsApiRepositoryProvider)));
class TagService {
final TagsApiRepository _repository;
const TagService(this._repository);
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
return _repository.bulkTagAssets(assetIds, tagIds);
}
Future<Set<Tag>> getAllTags() async {
final dtos = await _repository.getAllTags();
if (dtos == null) {
return {};
}
return dtos.map((dto) => Tag.fromDto(dto)).toSet();
}
Future<List<Tag>> upsertTags(List<String> tags) async {
final dtos = await _repository.upsertTags(tags);
if (dtos == null) {
return [];
}
return dtos.map((dto) => Tag.fromDto(dto)).toList();
}
}
@@ -4,8 +4,6 @@ extension StringExtension on String {
String capitalize() { String capitalize() {
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" "); return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
} }
String? get nullIfEmpty => isEmpty ? null : this;
} }
extension DurationExtension on String { extension DurationExtension on String {
@@ -1,7 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
@@ -31,6 +32,10 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase( @DriftDatabase(
tables: [ tables: [
@@ -60,8 +65,9 @@ import 'package:logging/logging.dart';
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
) )
class Drift extends $Drift { class Drift extends $Drift {
Drift([QueryExecutor? executor]) Drift(super.executor);
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
Future<void> reset() async { Future<void> reset() async {
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94 // https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
@@ -305,3 +311,18 @@ class DriftDatabaseRepository {
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback); Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
} }
Future<SqliteConnection> openSqliteConnection({required String name}) async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, '$name.sqlite'));
return SqliteDatabase(path: file.path);
}
Future<void> configureSqliteCache() async {
// Make sqlite3 pick a more suitable location for temporary files - the
// one from the system may be inaccessible due to sand-boxing.
final cacheBase = (await getTemporaryDirectory()).path;
// We can't access /tmp on Android, which sqlite3 would try by default.
// Explicitly tell it about the correct temporary directory.
sqlite3.tempDirectory = cacheBase;
}
@@ -1,14 +1,14 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(tables: [LogMessageEntity]) @DriftDatabase(tables: [LogMessageEntity])
class DriftLogger extends $DriftLogger { class DriftLogger extends $DriftLogger {
DriftLogger([QueryExecutor? executor]) DriftLogger.fromExecutor(super.executor);
: super(
executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)), DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
);
@override @override
int get schemaVersion => 1; int get schemaVersion => 1;
@@ -19,7 +19,8 @@ class DriftLogger extends $DriftLogger {
await customStatement('PRAGMA foreign_keys = ON'); await customStatement('PRAGMA foreign_keys = ON');
await customStatement('PRAGMA synchronous = NORMAL'); await customStatement('PRAGMA synchronous = NORMAL');
await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA journal_mode = WAL');
await customStatement('PRAGMA busy_timeout = 500'); await customStatement('PRAGMA busy_timeout = 30000'); // 30s
await customStatement('PRAGMA cache_size = -32000'); // 32MB
await customStatement('PRAGMA temp_store = MEMORY'); await customStatement('PRAGMA temp_store = MEMORY');
}, },
); );
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -140,38 +139,9 @@ extension<T extends Object> on MetadataDomain<T> {
autoPlayVideo: repo._read(.viewerAutoPlayVideo), autoPlayVideo: repo._read(.viewerAutoPlayVideo),
tapToNavigate: repo._read(.viewerTapToNavigate), tapToNavigate: repo._read(.viewerTapToNavigate),
), ),
slideshow: .new(
transition: repo._read(.slideshowTransition),
repeat: repo._read(.slideshowRepeat),
duration: repo._read(.slideshowDuration),
look: repo._read(.slideshowLook),
direction: repo._read(.slideshowDirection),
),
album: .new(
sortMode: repo._read(.albumSortMode),
isReverse: repo._read(.albumIsReverse),
isGrid: repo._read(.albumIsGrid),
),
backup: .new(
enabled: repo._read(.backupEnabled),
useCellularForVideos: repo._read(.backupUseCellularForVideos),
useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
requireCharging: repo._read(.backupRequireCharging),
triggerDelay: repo._read(.backupTriggerDelay),
syncAlbums: repo._read(.backupSyncAlbums),
),
); );
case .systemConfig: case .systemConfig:
repo._systemConfig = .new( repo._systemConfig = .new(logLevel: repo._read(.logLevel));
logLevel: repo._read(.logLevel),
network: .new(
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
externalEndpointList: repo._read(.networkExternalEndpointList),
customHeaders: repo._read(.networkCustomHeaders),
),
);
} }
} }
} }
@@ -10,7 +10,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
enum SortRemoteAlbumsBy { id, updatedAt } enum SortRemoteAlbumsBy { id, updatedAt }
@@ -160,7 +159,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
createdAt: Value(album.createdAt), createdAt: Value(album.createdAt),
updatedAt: Value(album.updatedAt), updatedAt: Value(album.updatedAt),
description: Value(album.description), description: Value(album.description),
thumbnailAssetId: Value(album.thumbnailAssetId ?? (assetIds.isNotEmpty ? assetIds.first : null)), thumbnailAssetId: Value(album.thumbnailAssetId),
isActivityEnabled: Value(album.isActivityEnabled), isActivityEnabled: Value(album.isActivityEnabled),
order: Value(album.order), order: Value(album.order),
); );
@@ -275,59 +274,17 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
} }
Future<int> addAssets(String albumId, List<String> assetIds) async { Future<int> addAssets(String albumId, List<String> assetIds) async {
if (assetIds.isEmpty) {
return 0;
}
final albumAssets = assetIds.map( final albumAssets = assetIds.map(
(assetId) => RemoteAlbumAssetEntityCompanion(albumId: Value(albumId), assetId: Value(assetId)), (assetId) => RemoteAlbumAssetEntityCompanion(albumId: Value(albumId), assetId: Value(assetId)),
); );
await _db.transaction(() async { await _db.batch((batch) {
await _db.batch((batch) { batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets);
batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets);
});
final album = _db.update(_db.remoteAlbumEntity)
..where((row) => row.id.equals(albumId) & row.thumbnailAssetId.isNull());
await album.write(RemoteAlbumEntityCompanion(thumbnailAssetId: Value(assetIds.first)));
}); });
return assetIds.length; return assetIds.length;
} }
/// Inserts a placeholder `remote_asset_entity` row from a freshly-uploaded
/// local asset. Skips silently if a row with the same id or
/// (owner_id, checksum) already exists — sync will overwrite with the
/// authoritative server data once the AssetUploadReadyV1 event is processed.
Future<void> upsertRemoteAssetStub({
required String remoteId,
required String ownerId,
required LocalAsset source,
}) async {
await _db
.into(_db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion(
id: Value(remoteId),
ownerId: Value(ownerId),
checksum: Value(source.checksum ?? remoteId),
name: Value(source.name),
type: Value(source.type),
createdAt: Value(source.createdAt),
updatedAt: Value(source.updatedAt),
width: Value(source.width),
height: Value(source.height),
durationMs: Value(source.durationMs),
isFavorite: Value(source.isFavorite),
visibility: const Value(AssetVisibility.timeline),
isEdited: Value(source.isEdited),
),
mode: InsertMode.insertOrIgnore,
);
}
Future<void> addUsers(String albumId, List<String> userIds) { Future<void> addUsers(String albumId, List<String> userIds) {
final albumUsers = userIds.map( final albumUsers = userIds.map(
(assetId) => RemoteAlbumUserEntityCompanion( (assetId) => RemoteAlbumUserEntityCompanion(
@@ -14,13 +14,4 @@ class TagsApiRepository extends ApiRepository {
Future<List<TagResponseDto>?> getAllTags() async { Future<List<TagResponseDto>?> getAllTags() async {
return await _api.getAllTags(); return await _api.getAllTags();
} }
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds));
return response?.count ?? 0;
}
Future<List<TagResponseDto>?> upsertTags(List<String> tags) async {
return _api.upsertTags(TagUpsertDto(tags: tags));
}
} }
@@ -8,13 +8,13 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; 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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.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/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart'; import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
_searchController = TextEditingController(); _searchController = TextEditingController();
_searchFocusNode = FocusNode(); _searchFocusNode = FocusNode();
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums; _enableSyncUploadAlbum.value = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
ref.read(backupAlbumProvider.notifier).getAll(); ref.read(backupAlbumProvider.notifier).getAll();
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount)); _initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
return; return;
} }
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums; final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final selectedAlbums = ref final selectedAlbums = ref
.read(backupAlbumProvider) .read(backupAlbumProvider)
.where((a) => a.backupSelection == BackupSelection.selected) .where((a) => a.backupSelection == BackupSelection.selected)
@@ -103,7 +103,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
return; return;
} }
final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled; final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id); await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount)); final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount; final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
@@ -3,12 +3,14 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -19,20 +21,18 @@ class DriftBackupOptionsPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
bool hasPopped = false; bool hasPopped = false;
final previousBackup = ref.read(metadataProvider).appConfig.backup; final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
final previousCellularForVideos = previousBackup.useCellularForVideos; final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, result) async { onPopInvokedWithResult: (didPop, result) async {
// There is an issue with Flutter where the pop event // There is an issue with Flutter where the pop event
// can be triggered multiple times, so we guard it with _hasPopped // can be triggered multiple times, so we guard it with _hasPopped
final currentBackup = ref.read(metadataProvider).appConfig.backup; final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
final currentCellularForVideos = currentBackup.useCellularForVideos; final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
final currentCellularForPhotos = currentBackup.useCellularForPhotos;
if (currentCellularForVideos == previousCellularForVideos && if (currentWifiReqForVideos == previousWifiReqForVideos &&
currentCellularForPhotos == previousCellularForPhotos) { currentWifiReqForPhotos == previousWifiReqForPhotos) {
return; return;
} }
@@ -45,7 +45,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
} }
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled; final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (!isBackupEnabled) { if (!isBackupEnabled) {
return; return;
} }
@@ -1,12 +1,14 @@
import 'dart:convert';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
class SettingsHeader { class SettingsHeader {
String key = ""; String key = "";
@@ -22,14 +24,17 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState<List<SettingsHeader>>([]); final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false); final setInitialHeaders = useState(false);
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders; var headersStr = Store.get(StoreKey.customHeaders, "");
if (!setInitialHeaders.value) { if (!setInitialHeaders.value) {
storedHeaders.forEach((k, v) { if (headersStr.isNotEmpty) {
final header = SettingsHeader(); var customHeaders = jsonDecode(headersStr) as Map;
header.key = k; customHeaders.forEach((k, v) {
header.value = v; final header = SettingsHeader();
headers.value.add(header); header.key = k;
}); header.value = v;
headers.value.add(header);
});
}
// add first one to help the user // add first one to help the user
if (headers.value.isEmpty) { if (headers.value.isEmpty) {
@@ -83,8 +88,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
} }
saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async { saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
final headersMap = <String, String>{}; final headersMap = {};
for (final header in headers) { for (var header in headers) {
final key = header.key.trim(); final key = header.key.trim();
final value = header.value.trim(); final value = header.value.trim();
@@ -94,7 +99,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
headersMap[key] = value; headersMap[key] = value;
} }
await ref.read(metadataProvider).write(MetadataKey.networkCustomHeaders, headersMap); var encoded = jsonEncode(headersMap);
await Store.put(StoreKey.customHeaders, encoded);
await ref.read(apiServiceProvider).updateHeaders(); await ref.read(apiServiceProvider).updateHeaders();
} }
} }
@@ -12,7 +12,6 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
@@ -341,7 +340,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
await backgroundManager.hashAssets(); await backgroundManager.hashAssets();
} }
if (MetadataRepository.instance.appConfig.backup.syncAlbums) { if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum(); await backgroundManager.syncLinkedAlbum();
} }
} catch (e) { } catch (e) {
@@ -370,7 +369,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
} }
Future<void> _resumeBackup(DriftBackupNotifier notifier) async { Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
final isEnableBackup = MetadataRepository.instance.appConfig.backup.enabled; final isEnableBackup = Store.get(StoreKey.enableBackup, false);
if (isEnableBackup) { if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser); final currentUser = Store.tryGet(StoreKey.currentUser);
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart'; import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart'; import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart';
@@ -27,41 +28,71 @@ class SharedLinkPage extends HookConsumerWidget {
}, []); }, []);
Widget buildNoShares() { Widget buildNoShares() {
return Center( return Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, children: [
mainAxisAlignment: MainAxisAlignment.center, Padding(
children: [ padding: const EdgeInsets.only(left: 16.0, top: 16.0),
Icon(Icons.link_off, size: 100, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)), child: const Text(
const SizedBox(height: 20), "shared_link_manage_links",
const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(), style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
], ).tr(),
), ),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
),
),
Expanded(
child: Center(
child: Icon(Icons.link_off, size: 100, color: context.themeData.iconTheme.color?.withValues(alpha: 0.5)),
),
),
],
); );
} }
Widget buildSharesList(List<SharedLink> links) { Widget buildSharesList(List<SharedLink> links) {
return LayoutBuilder( return Column(
builder: (context, constraints) => constraints.maxWidth > 600 crossAxisAlignment: CrossAxisAlignment.start,
? GridView.builder( children: [
key: const PageStorageKey('shared-links-grid'), Padding(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0),
crossAxisCount: 2, child: Text(
mainAxisExtent: 180, "shared_link_manage_links",
crossAxisSpacing: 12, style: context.textTheme.labelLarge?.copyWith(color: context.textTheme.labelLarge?.color?.withAlpha(200)),
mainAxisSpacing: 12, ).tr(),
), ),
padding: const EdgeInsets.all(12), Expanded(
itemCount: links.length, child: LayoutBuilder(
itemBuilder: (context, index) => SharedLinkItem(links[index]), builder: (context, constraints) {
) if (constraints.maxWidth > 600) {
: ListView.separated( // Two column
key: const PageStorageKey('shared-links-list'), return GridView.builder(
padding: const EdgeInsets.symmetric(vertical: 8), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
itemCount: links.length, crossAxisCount: 2,
itemBuilder: (context, index) => SharedLinkItem(links[index]), mainAxisExtent: 180,
separatorBuilder: (context, index) => const Divider(height: 1), ),
), itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
}
// Single column
return ListView.builder(
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
},
),
),
],
); );
} }
@@ -6,20 +6,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart'; import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/services/shared_link.service.dart'; import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:share_plus/share_plus.dart';
@RoutePage() @RoutePage()
class SharedLinkEditPage extends HookConsumerWidget { class SharedLinkEditPage extends HookConsumerWidget {
static const int maxFutureDate = 365 * 2;
final SharedLink? existingLink; final SharedLink? existingLink;
final List<String>? assetsList; final List<String>? assetsList;
final String? albumId; final String? albumId;
@@ -28,82 +23,71 @@ class SharedLinkEditPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
const padding = 20.0;
final themeData = context.themeData; final themeData = context.themeData;
final colorScheme = context.colorScheme; final colorScheme = context.colorScheme;
final externalDomain = ref.watch(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
final displayServerUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
final expiryPresets = <(Duration, String)>[
(Duration.zero, context.t.never),
(const Duration(minutes: 30), context.t.shared_link_edit_expire_after_option_minutes(count: 30)),
(const Duration(hours: 1), context.t.shared_link_edit_expire_after_option_hour),
(const Duration(hours: 6), context.t.shared_link_edit_expire_after_option_hours(count: 6)),
(const Duration(days: 1), context.t.shared_link_edit_expire_after_option_day),
(const Duration(days: 7), context.t.shared_link_edit_expire_after_option_days(count: 7)),
(const Duration(days: 30), context.t.shared_link_edit_expire_after_option_days(count: 30)),
(const Duration(days: 90), context.t.shared_link_edit_expire_after_option_months(count: 3)),
(const Duration(days: 365), context.t.shared_link_edit_expire_after_option_year(count: 1)),
];
final descriptionController = useTextEditingController(text: existingLink?.description ?? ""); final descriptionController = useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode(); final descriptionFocusNode = useFocusNode();
final passwordController = useTextEditingController(text: existingLink?.password ?? ""); final passwordController = useTextEditingController(text: existingLink?.password ?? "");
final slugController = useTextEditingController(text: existingLink?.slug ?? ""); final slugController = useTextEditingController(text: existingLink?.slug ?? "");
final slugFocusNode = useFocusNode(); final slugFocusNode = useFocusNode();
useListenable(slugController);
final showMetadata = useState(existingLink?.showMetadata ?? true); final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true); final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false); final allowUpload = useState(existingLink?.allowUpload ?? false);
final expiryAfter = useState<DateTime?>(existingLink?.expiresAt?.toLocal()); final editExpiry = useState(false);
final selectedPresetIndex = useState<int?>(existingLink?.expiresAt == null ? 0 : null); final expiryAfter = useState(0);
final newShareLink = useState(""); final newShareLink = useState("");
Widget buildSharedLinkRow({required String leading, required String content}) {
return Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
content,
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
),
),
),
const SizedBox(width: 8),
Text(leading, style: const TextStyle(fontWeight: FontWeight.bold)),
],
);
}
Widget buildLinkTitle() { Widget buildLinkTitle() {
if (existingLink != null) { if (existingLink != null) {
if (existingLink!.type == SharedLinkSource.album) { if (existingLink!.type == SharedLinkSource.album) {
return buildSharedLinkRow(leading: context.t.public_album, content: existingLink!.title); return Row(
children: [
const Text('public_album', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
Text(
existingLink!.title,
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
),
],
);
} }
if (existingLink!.type == SharedLinkSource.individual) { if (existingLink!.type == SharedLinkSource.individual) {
return buildSharedLinkRow( return Row(
leading: context.t.shared_link_individual_shared, children: [
content: existingLink!.description ?? "--", const Text('shared_link_individual_shared', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: Text(
existingLink!.description ?? "--",
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
],
); );
} }
} }
return Text(context.t.create_link_to_share_description, style: const TextStyle(fontWeight: FontWeight.bold)); return const Text("create_link_to_share_description", style: TextStyle(fontWeight: FontWeight.bold)).tr();
} }
Widget buildDescriptionField() { Widget buildDescriptionField() {
return TextField( return TextField(
controller: descriptionController, controller: descriptionController,
enabled: newShareLink.value.isEmpty,
focusNode: descriptionFocusNode, focusNode: descriptionFocusNode,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
autofocus: false, autofocus: false,
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.t.description, labelText: 'description'.tr(),
labelStyle: const TextStyle(fontWeight: FontWeight.bold), labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
floatingLabelBehavior: FloatingLabelBehavior.always, floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
hintText: context.t.shared_link_edit_description_hint, hintText: 'shared_link_edit_description_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
), ),
onTapOutside: (_) => descriptionFocusNode.unfocus(), onTapOutside: (_) => descriptionFocusNode.unfocus(),
); );
@@ -112,14 +96,16 @@ class SharedLinkEditPage extends HookConsumerWidget {
Widget buildPasswordField() { Widget buildPasswordField() {
return TextField( return TextField(
controller: passwordController, controller: passwordController,
enabled: newShareLink.value.isEmpty,
autofocus: false, autofocus: false,
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.t.password, labelText: 'password'.tr(),
labelStyle: const TextStyle(fontWeight: FontWeight.bold), labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
floatingLabelBehavior: FloatingLabelBehavior.always, floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
hintText: context.t.shared_link_edit_password_hint, hintText: 'shared_link_edit_password_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
), ),
); );
} }
@@ -127,16 +113,18 @@ class SharedLinkEditPage extends HookConsumerWidget {
Widget buildSlugField() { Widget buildSlugField() {
return TextField( return TextField(
controller: slugController, controller: slugController,
enabled: newShareLink.value.isEmpty,
focusNode: slugFocusNode, focusNode: slugFocusNode,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
autofocus: false, autofocus: false,
decoration: InputDecoration( decoration: InputDecoration(
labelText: slugController.text.isNotEmpty ? context.t.custom_url : null, labelText: 'custom_url'.tr(),
labelStyle: const TextStyle(fontWeight: FontWeight.bold), labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
hintText: context.t.custom_url, hintText: 'custom_url'.tr(),
prefixText: slugController.text.isNotEmpty ? '/s/' : null, hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
prefixStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
), ),
onTapOutside: (_) => slugFocusNode.unfocus(), onTapOutside: (_) => slugFocusNode.unfocus(),
); );
@@ -145,182 +133,145 @@ class SharedLinkEditPage extends HookConsumerWidget {
Widget buildShowMetaButton() { Widget buildShowMetaButton() {
return SwitchListTile.adaptive( return SwitchListTile.adaptive(
value: showMetadata.value, value: showMetadata.value,
onChanged: (value) => showMetadata.value = value, onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
activeThumbColor: colorScheme.primary,
dense: true, dense: true,
title: Text( title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
context.t.show_metadata,
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
); );
} }
Widget buildAllowDownloadButton() { Widget buildAllowDownloadButton() {
return SwitchListTile.adaptive( return SwitchListTile.adaptive(
value: allowDownload.value, value: allowDownload.value,
onChanged: (value) => allowDownload.value = value, onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
activeThumbColor: colorScheme.primary,
dense: true, dense: true,
title: Text( title: Text(
context.t.allow_public_user_to_download, "allow_public_user_to_download",
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
), ).tr(),
); );
} }
Widget buildAllowUploadButton() { Widget buildAllowUploadButton() {
return SwitchListTile.adaptive( return SwitchListTile.adaptive(
value: allowUpload.value, value: allowUpload.value,
onChanged: (value) => allowUpload.value = value, onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
activeThumbColor: colorScheme.primary,
dense: true, dense: true,
title: Text( title: Text(
context.t.allow_public_user_to_upload, "allow_public_user_to_upload",
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
), ).tr(),
); );
} }
String formatDateTime(DateTime dateTime) => DateFormat.yMMMd(context.locale.toString()).add_Hm().format(dateTime); Widget buildEditExpiryButton() {
return SwitchListTile.adaptive(
DateTime? getExpiresAtFromPreset(Duration preset) => preset == Duration.zero ? null : DateTime.now().add(preset); value: editExpiry.value,
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
Future<void> selectDate() async { activeThumbColor: colorScheme.primary,
final today = DateTime.now(); dense: true,
final safeInitialDate = expiryAfter.value ?? today.add(const Duration(days: 7)); title: Text(
final initialDate = safeInitialDate.isBefore(today) ? today : safeInitialDate; "change_expiration_time",
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
final selectedDate = await showDatePicker( ).tr(),
context: context,
initialDate: initialDate,
firstDate: today,
lastDate: today.add(const Duration(days: maxFutureDate)),
); );
if (selectedDate != null && context.mounted) {
final isToday =
selectedDate.year == today.year && selectedDate.month == today.month && selectedDate.day == today.day;
final initialTime = isToday ? TimeOfDay.fromDateTime(today) : const TimeOfDay(hour: 12, minute: 0);
final selectedTime = await showTimePicker(context: context, initialTime: initialTime);
if (selectedTime != null) {
final now = DateTime.now();
var finalDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
selectedTime.hour,
selectedTime.minute,
);
if (finalDateTime.isBefore(now) && isToday) {
finalDateTime = now;
}
selectedPresetIndex.value = null;
expiryAfter.value = finalDateTime;
}
}
} }
Widget buildExpiryAfterButton() { Widget buildExpiryAfterButton() {
return ExpansionTile( return DropdownMenu(
title: Text( label: Text(
context.t.expire_after, "expire_after",
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
), ).tr(),
subtitle: Text( enableSearch: false,
expiryAfter.value == null ? context.t.shared_link_expires_never : formatDateTime(expiryAfter.value!), enableFilter: false,
style: TextStyle(color: themeData.colorScheme.primary), width: context.width - 40,
), initialSelection: expiryAfter.value,
enabled: newShareLink.value.isEmpty && (existingLink == null || editExpiry.value),
onSelected: (value) {
expiryAfter.value = value!;
},
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "never".tr()),
DropdownMenuEntry(
value: 30,
label: "shared_link_edit_expire_after_option_minutes".tr(namedArgs: {'count': "30"}),
),
DropdownMenuEntry(value: 60, label: "shared_link_edit_expire_after_option_hour".tr()),
DropdownMenuEntry(
value: 60 * 6,
label: "shared_link_edit_expire_after_option_hours".tr(namedArgs: {'count': "6"}),
),
DropdownMenuEntry(value: 60 * 24, label: "shared_link_edit_expire_after_option_day".tr()),
DropdownMenuEntry(
value: 60 * 24 * 7,
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "7"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30,
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "30"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30 * 3,
label: "shared_link_edit_expire_after_option_months".tr(namedArgs: {'count': "3"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30 * 12,
label: "shared_link_edit_expire_after_option_year".tr(namedArgs: {'count': "1"}),
),
],
);
}
void copyLinkToClipboard() {
Clipboard.setData(ClipboardData(text: newShareLink.value)).then((_) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
"shared_link_clipboard_copied_massage",
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
).tr(),
duration: const Duration(seconds: 2),
),
);
});
}
Widget buildNewLinkField() {
return Column(
children: [ children: [
const Padding(padding: EdgeInsets.only(top: 20, bottom: 20), child: Divider()),
TextFormField(
readOnly: true,
initialValue: newShareLink.value,
decoration: InputDecoration(
border: const OutlineInputBorder(),
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
suffixIcon: IconButton(onPressed: copyLinkToClipboard, icon: const Icon(Icons.copy)),
),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.only(top: 16.0),
child: Column( child: Align(
crossAxisAlignment: CrossAxisAlignment.start, alignment: Alignment.bottomRight,
children: [ child: ElevatedButton(
Wrap( onPressed: () {
spacing: 8, context.maybePop();
runSpacing: 8, },
children: List.generate(expiryPresets.length, (index) { child: const Text("done", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
final preset = expiryPresets[index]; ),
return ChoiceChip(
label: Text(preset.$2),
selected: selectedPresetIndex.value == index,
onSelected: (_) {
selectedPresetIndex.value = index;
expiryAfter.value = getExpiresAtFromPreset(preset.$1);
},
);
}),
),
if (expiryAfter.value != null) ...[
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: selectDate,
icon: const Icon(Icons.edit_calendar),
label: Text(context.t.edit_date_and_time),
),
),
],
],
), ),
), ),
], ],
); );
} }
Future<void> copyToClipboard(String link) async { DateTime calculateExpiry() {
await Clipboard.setData(ClipboardData(text: link)); return DateTime.now().add(Duration(minutes: expiryAfter.value));
if (!context.mounted) {
return;
}
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.t.shared_link_clipboard_copied_massage,
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
duration: const Duration(seconds: 2),
),
);
} }
Widget buildLinkCopyField(String link) {
return TextFormField(
readOnly: true,
onTap: () => copyToClipboard(link),
initialValue: link,
decoration: InputDecoration(
border: const OutlineInputBorder(),
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
suffixIcon: IconButton(onPressed: () => Share.share(link), icon: const Icon(Icons.share)),
),
);
}
Widget buildNewLinkReadyScreen() {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_link, size: 100, color: themeData.colorScheme.primary),
const SizedBox(height: 20),
buildLinkCopyField(newShareLink.value),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => context.maybePop(),
icon: const Icon(Icons.check),
label: Text(context.t.done, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
),
],
),
);
}
DateTime? calculateExpiry() => expiryAfter.value;
Future<void> handleNewLink() async { Future<void> handleNewLink() async {
final newLink = await ref final newLink = await ref
.read(sharedLinkServiceProvider) .read(sharedLinkServiceProvider)
@@ -333,30 +284,30 @@ class SharedLinkEditPage extends HookConsumerWidget {
description: descriptionController.text.isEmpty ? null : descriptionController.text, description: descriptionController.text.isEmpty ? null : descriptionController.text,
password: passwordController.text.isEmpty ? null : passwordController.text, password: passwordController.text.isEmpty ? null : passwordController.text,
slug: slugController.text.isEmpty ? null : slugController.text, slug: slugController.text.isEmpty ? null : slugController.text,
expiresAt: calculateExpiry()?.toUtc(), expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
); );
if (!context.mounted) {
return;
}
ref.invalidate(sharedLinksStateProvider); ref.invalidate(sharedLinksStateProvider);
await ref.read(serverInfoProvider.notifier).getServerConfig(); await ref.read(serverInfoProvider.notifier).getServerConfig();
if (!context.mounted) {
return;
}
final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain)); final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
final serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
if (serverUrl != null && !serverUrl.endsWith('/')) {
serverUrl += '/';
}
if (newLink != null) { if (newLink != null && serverUrl != null) {
newShareLink.value = buildSharedLinkUrl(baseUrl: serverUrl, slug: newLink.slug, key: newLink.key) ?? ''; final hasSlug = newLink.slug?.isNotEmpty == true;
await copyToClipboard(newShareLink.value); final urlPath = hasSlug ? newLink.slug : newLink.key;
} else { final basePath = hasSlug ? 's' : 'share';
newShareLink.value = "$serverUrl$basePath/$urlPath";
copyLinkToClipboard();
} else if (newLink == null) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
toastType: ToastType.error, toastType: ToastType.error,
msg: context.t.shared_link_create_error, msg: 'shared_link_create_error'.tr(),
); );
} }
} }
@@ -397,9 +348,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
slug = existingLink!.slug; slug = existingLink!.slug;
} }
final newExpiry = expiryAfter.value; if (editExpiry.value) {
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) { expiry = expiryAfter.value == 0 ? null : calculateExpiry();
expiry = newExpiry;
changeExpiry = true; changeExpiry = true;
} }
@@ -413,115 +363,69 @@ class SharedLinkEditPage extends HookConsumerWidget {
description: desc, description: desc,
password: password, password: password,
slug: slug, slug: slug,
expiresAt: expiry?.toUtc(), expiresAt: expiry,
changeExpiry: changeExpiry, changeExpiry: changeExpiry,
); );
if (!context.mounted) {
return;
}
ref.invalidate(sharedLinksStateProvider); ref.invalidate(sharedLinksStateProvider);
await context.maybePop(); await context.maybePop();
} }
Future<void> handleDeleteLink() async {
return showDialog(
context: context,
builder: (BuildContext context) => ConfirmDialog(
title: "delete_shared_link_dialog_title",
content: "confirm_delete_shared_link",
onOk: () async {
await ref.read(sharedLinkServiceProvider).deleteSharedLink(existingLink!.id);
ref.invalidate(sharedLinksStateProvider);
if (context.mounted) {
await context.maybePop();
}
},
),
);
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(existingLink == null ? context.t.create_link_to_share : context.t.edit_link), title: Text(existingLink == null ? "create_link_to_share" : "edit_link").tr(),
elevation: 0, elevation: 0,
leading: const CloseButton(), leading: const CloseButton(),
centerTitle: false, centerTitle: false,
), ),
body: SafeArea( body: SafeArea(
child: newShareLink.value.isEmpty child: ListView(
? Padding( children: [
padding: const EdgeInsets.symmetric(horizontal: 20), Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()),
child: ListView( Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()),
children: [ Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()),
const SizedBox(height: 20), Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()),
buildLinkTitle(), Padding(
if (existingLink != null) padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
Column( child: buildShowMetaButton(),
crossAxisAlignment: CrossAxisAlignment.center, ),
children: [ Padding(
const SizedBox(height: 16), padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
buildLinkCopyField( child: buildAllowDownloadButton(),
buildSharedLinkUrl( ),
baseUrl: displayServerUrl, Padding(
slug: existingLink!.slug, padding: const EdgeInsets.only(left: padding, right: 20, bottom: 20),
key: existingLink!.key, child: buildAllowUploadButton(),
) ?? ),
'', if (existingLink != null)
), Padding(
const SizedBox(height: 24), padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
const Divider(), child: buildEditExpiryButton(),
], ),
), Padding(
const SizedBox(height: 24), padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
buildDescriptionField(), child: buildExpiryAfterButton(),
const SizedBox(height: 16), ),
buildPasswordField(), if (newShareLink.value.isEmpty)
const SizedBox(height: 16), Align(
buildSlugField(), alignment: Alignment.bottomRight,
const SizedBox(height: 16), child: Padding(
buildShowMetaButton(), padding: const EdgeInsets.only(right: padding + 10, bottom: padding),
const SizedBox(height: 16), child: ElevatedButton(
buildAllowDownloadButton(), onPressed: existingLink != null ? handleEditLink : handleNewLink,
const SizedBox(height: 16), child: Text(
buildAllowUploadButton(), existingLink != null ? "shared_link_edit_submit_button" : "create_link",
const SizedBox(height: 16), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
buildExpiryAfterButton(), ).tr(),
const SizedBox(height: 24), ),
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
if (existingLink != null)
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: themeData.colorScheme.error,
side: BorderSide(color: themeData.colorScheme.error),
),
onPressed: handleDeleteLink,
icon: const Icon(Icons.delete_outline),
label: Text(
context.t.delete,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
),
ElevatedButton.icon(
icon: const Icon(Icons.check),
onPressed: existingLink != null ? handleEditLink : handleNewLink,
label: Text(
existingLink != null ? context.t.shared_link_edit_submit_button : context.t.create_link,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
),
],
),
),
const SizedBox(height: 40),
],
), ),
) ),
: Center(child: buildNewLinkReadyScreen()), if (newShareLink.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildNewLinkField(),
),
],
),
), ),
); );
} }
@@ -37,7 +37,6 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
final scrollView = CustomScrollView( final scrollView = CustomScrollView(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
ImmichSliverAppBar( ImmichSliverAppBar(
snap: false, snap: false,
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage() @RoutePage()
class DriftAssetSelectionTimelinePage extends ConsumerWidget { class DriftAssetSelectionTimelinePage extends ConsumerWidget {
@@ -21,13 +22,17 @@ class DriftAssetSelectionTimelinePage extends ConsumerWidget {
), ),
), ),
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? []; final user = ref.watch(currentUserProvider);
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers); if (user == null) {
throw Exception('User must be logged in to access asset selection timeline');
}
final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(user.id);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
], ],
child: const Timeline(showStorageIndicator: true), child: const Timeline(),
); );
} }
} }
@@ -179,14 +179,17 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
} }
final album = await ref final album = await ref
.read(remoteAlbumProvider.notifier) .watch(remoteAlbumProvider.notifier)
.createAlbumWithAssets( .createAlbum(
title: title, title: title,
description: albumDescriptionController.text.trim(), description: albumDescriptionController.text.trim(),
assets: selectedAssets, assetIds: selectedAssets.map((asset) {
final remoteAsset = asset as RemoteAsset;
return remoteAsset.id;
}).toList(),
); );
if (album != null && context.mounted) { if (album != null) {
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album))); unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
} }
} }
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/pending_uploads_banner.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart'; import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
@@ -40,8 +39,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
} }
Future<void> addAssets(BuildContext context) async { Future<void> addAssets(BuildContext context) async {
final notifier = ref.read(remoteAlbumProvider.notifier); final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id);
final albumAssets = await notifier.getAssets(_album.id);
final newAssets = await context.pushRoute<Set<BaseAsset>>( final newAssets = await context.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()), DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
@@ -51,9 +49,17 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
return; return;
} }
final added = await notifier.addAssetsToAlbum(_album.id, newAssets); final added = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(
_album.id,
newAssets.map((asset) {
final remoteAsset = asset as RemoteAsset;
return remoteAsset.id;
}).toList(),
);
if (added > 0 && context.mounted) { if (added > 0) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}), msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}),
@@ -180,7 +186,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
currentRemoteAlbumScopedProvider.overrideWithValue(_album), currentRemoteAlbumScopedProvider.overrideWithValue(_album),
], ],
child: Timeline( child: Timeline(
topSliverWidget: PendingUploadsBanner(albumId: _album.id),
appBar: RemoteAlbumSliverAppBar( appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined, icon: Icons.photo_album_outlined,
kebabMenu: _AlbumKebabMenu( kebabMenu: _AlbumKebabMenu(
@@ -1,376 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class DriftSlideshowPage extends ConsumerStatefulWidget {
final TimelineService timeline;
const DriftSlideshowPage({super.key, required this.timeline});
@override
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
}
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
late SlideshowConfig _config;
late final PageController _pageController;
late final Stopwatch _stopwatch;
late Timer _timer;
late int _index;
late int _nextIndex;
bool _paused = false;
bool _showAppBar = false;
@override
initState() {
super.initState();
_config = ref.read(appConfigProvider.select((s) => s.slideshow));
final asset = ref.read(assetViewerProvider).currentAsset;
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
_pageController = PageController(initialPage: _index);
_stopwatch = Stopwatch();
_createTimer();
_updateNextIndex();
ref.listenManual(appConfigProvider.select((s) => s.slideshow), _onConfigChanged);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
unawaited(WakelockPlus.enable());
}
@override
dispose() {
_timer.cancel();
_stopwatch.stop();
_pageController.dispose();
unawaited(WakelockPlus.disable());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _play() {
final asset = widget.timeline.getAssetSafe(_index)!;
if (asset.isImage) {
_createTimer();
} else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).play();
} else {
_nextPage();
}
_updateNextIndex();
setState(() {
_paused = false;
});
}
void _pause() {
_timer.cancel();
_stopwatch.stop();
final asset = widget.timeline.getAssetSafe(_index)!;
if (!asset.isImage) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).pause();
}
setState(() {
_paused = true;
});
}
void _onConfigChanged(SlideshowConfig? previous, SlideshowConfig next) {
if (_config == next) {
return;
}
final durationChanged = _config.duration != next.duration;
_config = next;
_updateNextIndex();
final asset = widget.timeline.getAssetSafe(_index);
if (durationChanged && !_paused && asset?.isImage == true) {
_timer.cancel();
_createTimer();
}
setState(() {});
}
void _updateNextIndex() {
_nextIndex = switch (_config.direction) {
SlideshowDirection.forward => _index + 1,
SlideshowDirection.backward => _index - 1,
SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!,
};
if (!widget.timeline.hasRange(_nextIndex, 1)) {
widget.timeline.preloadAssets(_nextIndex);
}
}
void _nextPage() async {
if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) {
if (_config.repeat) {
final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
await widget.timeline.preloadAssets(wrapped);
_pageController.jumpToPage(wrapped);
} else {
setState(() {
_paused = true;
});
}
return;
}
if (!widget.timeline.hasRange(_nextIndex, 1)) {
await widget.timeline.preloadAssets(_nextIndex);
}
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
_pageController.jumpToPage(_nextIndex);
} else {
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
}
}
void _createTimer() {
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () {
_stopwatch.stop();
_stopwatch.reset();
_nextPage();
});
_stopwatch.start();
}
void _pageChanged(int page) {
final asset = widget.timeline.getAssetSafe(page)!;
setState(() {
_index = page;
if (!asset.isImage) {
_paused = false;
}
});
_timer.cancel();
_stopwatch.stop();
_stopwatch.reset();
if (!_paused && asset.isImage) {
_createTimer();
}
_updateNextIndex();
}
void _onTapUp() async {
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_showAppBar = !_showAppBar;
});
});
}
Widget _getProgressBar(BuildContext context) {
final asset = widget.timeline.getAssetSafe(_index);
if (asset == null) {
return Container();
}
if (asset.isImage) {
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
return TweenAnimationBuilder(
key: Key(_index.toString()),
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: value,
),
);
} else {
return LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value:
ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
asset.duration.inMilliseconds,
);
}
}
Widget _getBlur(BuildContext context, int index) {
final asset = widget.timeline.getAssetSafe(index);
if (asset == null) {
return Container();
}
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
fit: BoxFit.cover,
),
),
child: Container(color: Colors.black.withValues(alpha: 0.2)),
),
);
}
Widget _getPhotoView(BuildContext context, int index) {
final asset = widget.timeline.getAssetSafe(index);
if (asset == null) {
return const Center(child: ImmichLoadingIndicator());
}
final scale = _config.look == SlideshowLook.cover
? PhotoViewComputedScale.covered
: PhotoViewComputedScale.contained;
final isCurrent = _index == index;
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
if (asset.isImage) {
final zoomOut = index % 2 == 1;
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
return TweenAnimationBuilder(
tween: Tween<double>(
begin: progress,
end: _paused
? progress
: zoomOut
? 0.0
: 1.0,
),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => PhotoView(
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + value / 10.0),
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
),
);
} else {
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
final position = ref.read(videoPlayerProvider(asset.heroTag)).position;
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
_nextPage();
} else if (status == VideoPlaybackStatus.playing) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false);
}
return PhotoView.customChild(
onTapUp: (_, _, _) => _onTapUp(),
disableScaleGestures: true,
filterQuality: FilterQuality.high,
initialScale: scale,
child: NativeVideoViewer(
asset: asset,
isCurrent: isCurrent,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5),
child: IgnorePointer(
ignoring: !_showAppBar,
child: AnimatedOpacity(
opacity: _showAppBar ? 1.0 : 0.0,
duration: Durations.short2,
child: Column(
children: [
AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("slideshow".t(context: context)),
actions: [
IconButton(
onPressed: _paused ? _play : _pause,
icon: Icon(_paused ? Icons.play_arrow : Icons.pause),
),
IconButton(
onPressed: () {
_pause();
context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer));
},
icon: const Icon(Icons.settings),
),
],
),
_getProgressBar(context),
],
),
),
),
),
extendBody: true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
body: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: const FastClampingScrollPhysics(),
itemCount: widget.timeline.totalAssets,
onPageChanged: _pageChanged,
itemBuilder: (context, index) => Stack(
children: [
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
_getPhotoView(context, index),
],
),
),
),
);
}
}
@@ -186,7 +186,7 @@ class DriftSearchPage extends HookConsumerWidget {
expanded: true, expanded: true,
onSearch: handleApply, onSearch: handleApply,
onClear: handleClear, onClear: handleClear,
child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
), ),
), ),
); );
@@ -50,13 +50,10 @@ class BaseActionButton extends ConsumerWidget {
final iconColor = this.iconColor; final iconColor = this.iconColor;
return MenuItemButton( return MenuItemButton(
style: MenuItemButton.styleFrom( style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
alignment: Alignment.centerLeft, leadingIcon: Icon(iconData, color: iconColor),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
leadingIcon: Icon(iconData, color: iconColor, size: 20),
onPressed: onPressed, onPressed: onPressed,
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)), child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
); );
} }
@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class BulkTagAssetsActionButton extends ConsumerWidget {
final ActionSource source;
const BulkTagAssetsActionButton({super.key, required this.source});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
final result = await ref.read(actionProvider.notifier).tagAssets(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: result.success
? 'tagged_assets'.t(context: context, args: {'count': result.count.toString()})
: 'errors.failed_to_tag_assets'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.sell_outlined,
label: "control_bottom_app_bar_add_tags".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,34 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SlideshowActionButton extends ConsumerWidget {
final bool iconOnly;
final bool menuItem;
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) {
if (!context.mounted) {
return;
}
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.slideshow,
label: "slideshow".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -15,15 +15,15 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -58,11 +58,19 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final albumConfig = ref.read(metadataProvider).appConfig.album; final appSettings = ref.read(appSettingsServiceProvider);
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
final albumSortMode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == savedSortMode,
orElse: () => AlbumSortMode.lastModified,
);
setState(() { setState(() {
sort = AlbumSort(mode: albumConfig.sortMode, isReverse: albumConfig.isReverse); sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
isGrid = albumConfig.isGrid; isGrid = savedIsGrid;
}); });
ref.read(remoteAlbumProvider.notifier).refresh(); ref.read(remoteAlbumProvider.notifier).refresh();
@@ -94,7 +102,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
setState(() { setState(() {
isGrid = !isGrid; isGrid = !isGrid;
}); });
ref.read(metadataProvider).write(MetadataKey.albumIsGrid, isGrid); ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
} }
void changeFilter(QuickFilterMode mode) { void changeFilter(QuickFilterMode mode) {
@@ -110,9 +118,9 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
this.sort = sort; this.sort = sort;
}); });
final metadata = ref.read(metadataProvider); final appSettings = ref.read(appSettingsServiceProvider);
await metadata.write(MetadataKey.albumSortMode, sort.mode); await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
await metadata.write(MetadataKey.albumIsReverse, sort.isReverse); await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
await sortAlbums(); await sortAlbums();
} }
@@ -1,252 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
/// Pinned banner sliver that surfaces in-flight album uploads directly under
/// the album app bar. Renders nothing while the queue is empty. Tapping the
/// banner opens a bottom sheet with per-asset progress.
class PendingUploadsBanner extends ConsumerWidget {
static const double _height = 52;
final String albumId;
const PendingUploadsBanner({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pending = ref.watch(pendingAlbumUploadsProvider(albumId));
if (pending.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final hasFailures = pending.any((p) => p.failed);
final clamped = pending.map((p) => p.progress.clamp(0.0, 1.0)).toList(growable: false);
final overallProgress = clamped.isEmpty ? 0.0 : clamped.reduce((a, b) => a + b) / clamped.length;
final isIndeterminate = overallProgress <= 0.0;
return SliverPersistentHeader(
pinned: true,
delegate: _PendingUploadsBannerDelegate(
height: _height,
child: _PendingUploadsBannerContent(
albumId: albumId,
previewAsset: pending.first.asset,
count: pending.length,
overallProgress: overallProgress,
isIndeterminate: isIndeterminate,
hasFailures: hasFailures,
),
),
);
}
static void _openSheet(BuildContext context, String albumId) {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder: (_) => _PendingUploadsSheet(albumId: albumId),
);
}
}
class _PendingUploadsBannerDelegate extends SliverPersistentHeaderDelegate {
final double height;
final Widget child;
const _PendingUploadsBannerDelegate({required this.height, required this.child});
@override
double get minExtent => height;
@override
double get maxExtent => height;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;
@override
bool shouldRebuild(covariant _PendingUploadsBannerDelegate oldDelegate) =>
height != oldDelegate.height || child != oldDelegate.child;
}
class _PendingUploadsBannerContent extends StatelessWidget {
final String albumId;
final BaseAsset previewAsset;
final int count;
final double overallProgress;
final bool isIndeterminate;
final bool hasFailures;
const _PendingUploadsBannerContent({
required this.albumId,
required this.previewAsset,
required this.count,
required this.overallProgress,
required this.isIndeterminate,
required this.hasFailures,
});
@override
Widget build(BuildContext context) {
final percentLabel = isIndeterminate ? '' : ' · ${(overallProgress * 100).toInt()}%';
return Material(
color: hasFailures ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainerHigh,
child: InkWell(
onTap: () => PendingUploadsBanner._openSheet(context, albumId),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: SizedBox(width: 32, height: 32, child: Thumbnail.fromAsset(asset: previewAsset)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${'uploading'.t(context: context)} $count$percentLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
),
if (hasFailures)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(Icons.error_outline, color: context.colorScheme.error, size: 20),
),
Icon(Icons.chevron_right_rounded, color: context.colorScheme.onSurfaceVariant),
],
),
),
),
SizedBox(
height: 3,
child: LinearProgressIndicator(
value: isIndeterminate ? null : overallProgress,
backgroundColor: context.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
hasFailures ? context.colorScheme.error : context.colorScheme.primary,
),
),
),
],
),
),
);
}
}
class _PendingUploadsSheet extends ConsumerWidget {
final String albumId;
const _PendingUploadsSheet({required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pending = ref.watch(pendingAlbumUploadsProvider(albumId));
// Auto-dismiss when the queue empties.
if (pending.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
return const SizedBox.shrink();
}
final failedCount = pending.where((p) => p.failed).length;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(
child: Text(
'${'uploading'.t(context: context)} (${pending.length})',
style: context.textTheme.titleMedium,
),
),
if (failedCount > 0)
TextButton.icon(
onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(),
icon: const Icon(Icons.clear_rounded, size: 18),
label: Text('clear_failed_count'.t(context: context, args: {'count': failedCount})),
style: TextButton.styleFrom(foregroundColor: context.colorScheme.error),
),
],
),
),
SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: pending.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, index) => _PendingUploadTile(entry: pending[index]),
),
),
],
),
),
);
}
}
class _PendingUploadTile extends StatelessWidget {
final PendingAlbumUpload entry;
const _PendingUploadTile({required this.entry});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
width: 96,
height: 96,
child: Stack(
fit: StackFit.expand,
children: [
Thumbnail.fromAsset(asset: entry.asset),
Positioned.fill(
child: ColoredBox(
color: entry.failed ? Colors.red.withValues(alpha: 0.6) : Colors.black54,
child: Center(
child: entry.failed
? const Icon(Icons.error_outline, color: Colors.white, size: 28)
: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
value: entry.progress > 0 ? entry.progress : null,
strokeWidth: 2.5,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
),
),
],
),
),
);
}
}
@@ -56,13 +56,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_DragIntent _dragIntent = _DragIntent.none; _DragIntent _dragIntent = _DragIntent.none;
Drag? _drag; Drag? _drag;
BaseAsset? _asset;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) { if (!mounted || !_scrollController.hasClients) {
return; return;
@@ -74,14 +71,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}); });
} }
@override
void didUpdateWidget(AssetPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.index != widget.index) {
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
}
}
@override @override
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
@@ -394,7 +383,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final asset = _asset; final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
if (asset == null) { if (asset == null) {
return const Center(child: ImmichLoadingIndicator()); return const Center(child: ImmichLoadingIndicator());
} }
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
class BackupToggleButton extends ConsumerStatefulWidget { class BackupToggleButton extends ConsumerStatefulWidget {
final VoidCallback onStart; final VoidCallback onStart;
@@ -31,7 +31,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
end: 1, end: 1,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
_isEnabled = ref.read(metadataProvider).appConfig.backup.enabled; _isEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
} }
@override @override
@@ -41,7 +41,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
} }
Future<void> _onToggle(bool value) async { Future<void> _onToggle(bool value) async {
await ref.read(metadataProvider).write(MetadataKey.backupEnabled, value); await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.enableBackup, value);
setState(() { setState(() {
_isEnabled = value; _isEnabled = value;
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -27,7 +26,6 @@ import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.d
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -59,9 +57,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
final multiselect = ref.watch(multiSelectProvider); final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
Future<void> addAssetsToAlbum(RemoteAlbum album) async { Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets; final selectedAssets = multiselect.selectedAssets;
@@ -119,7 +114,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
: const DeletePermanentActionButton(source: ActionSource.timeline), : const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline),
@@ -120,9 +120,6 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
}, },
flightShuttleBuilder: (context, animation, direction, from, to) { flightShuttleBuilder: (context, animation, direction, from, to) {
void animationStatusListener(AnimationStatus status) { void animationStatusListener(AnimationStatus status) {
if (!mounted) {
return;
}
final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse; final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse;
if (_hideIndicators != heroInFlight) { if (_hideIndicators != heroInFlight) {
setState(() => _hideIndicators = heroInFlight); setState(() => _hideIndicators = heroInFlight);
@@ -242,11 +242,7 @@ class _AssetTileWidget extends ConsumerWidget {
return false; return false;
} }
// Iterate with `==` instead of `Set.contains` because `RemoteAsset.hashCode` return lockSelectionAssets.contains(asset);
// includes `localId` while `==` does not — so the same server asset can
// hash to a different bucket when its `localId` differs (e.g., album-fetched
// copy has localId=null, merged-timeline copy has it populated).
return lockSelectionAssets.any((a) => a == asset);
} }
@override @override
@@ -64,32 +64,36 @@ class Timeline extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return Scaffold(
builder: (_, constraints) => ProviderScope( resizeToAvoidBottomInset: false,
overrides: [ floatingActionButton: const DownloadStatusFloatingButton(),
timelineArgsProvider.overrideWith( body: LayoutBuilder(
(ref) => TimelineArgs( builder: (_, constraints) => ProviderScope(
maxWidth: constraints.maxWidth, overrides: [
maxHeight: constraints.maxHeight, timelineArgsProvider.overrideWith(
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)), (ref) => TimelineArgs(
showStorageIndicator: showStorageIndicator, maxWidth: constraints.maxWidth,
withStack: withStack, maxHeight: constraints.maxHeight,
groupBy: groupBy, columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
),
), ),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
), ),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
), ),
), ),
); );
@@ -375,126 +379,121 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
} }
}, },
child: PrimaryScrollController( child: asyncSegments.widgetWhen(
controller: _scrollController, onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
child: Scaffold( onData: (segments) {
resizeToAvoidBottomInset: false, final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
floatingActionButton: const DownloadStatusFloatingButton(), final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
body: asyncSegments.widgetWhen( ? 200
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null, : 0;
onData: (segments) { final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
? 200
: 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const bottomSheetOpenModifier = 120.0; const bottomSheetOpenModifier = 120.0;
final contentBottomPadding = final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
final grid = CustomScrollView( final grid = CustomScrollView(
primary: true, primary: true,
physics: _scrollPhysics, physics: _scrollPhysics,
cacheExtent: maxHeight * 2, cacheExtent: maxHeight * 2,
slivers: [ slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!, if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!, if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList( _SliverSegmentedList(
segments: segments, segments: segments,
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(ctx, index) { (ctx, index) {
if (index >= childCount) { if (index >= childCount) {
return null; return null;
} }
final segment = segments.findByIndex(index); final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink(); return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
],
);
final Widget timeline;
if (widget.withScrubber) {
timeline = Scrubber(
snapToMonth: widget.snapToMonth,
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,
);
} else {
timeline = grid;
}
return RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != _perRow) {
final targetAssetIndex = _getCurrentAssetIndex(segments);
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
_restoreAssetIndex = targetAssetIndex;
});
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow);
}
};
},
),
},
child: TimelineDragRegion(
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
onAssetEnter: _handleDragAssetEnter,
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
}, },
child: Stack( childCount: childCount,
clipBehavior: Clip.none, addAutomaticKeepAlives: false,
children: [ // We add repaint boundary around tiles, so skip the auto boundaries
timeline, addRepaintBoundaries: false,
if (isBottomWidgetVisible)
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(
height: kToolbarHeight,
child: Center(child: _MultiSelectStatusButton()),
),
),
if (isBottomWidgetVisible) widget.bottomSheet!,
],
),
), ),
); ),
}, if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
), SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
), ],
);
final Widget timeline;
if (widget.withScrubber) {
timeline = Scrubber(
snapToMonth: widget.snapToMonth,
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,
);
} else {
timeline = grid;
}
return PrimaryScrollController(
controller: _scrollController,
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != _perRow) {
final targetAssetIndex = _getCurrentAssetIndex(segments);
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
_restoreAssetIndex = targetAssetIndex;
});
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow);
}
};
},
),
},
child: TimelineDragRegion(
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
onAssetEnter: _handleDragAssetEnter,
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(
height: kToolbarHeight,
child: Center(child: _MultiSelectStatusButton()),
),
),
if (isBottomWidgetVisible) widget.bottomSheet!,
],
),
),
),
);
},
), ),
); );
} }
@@ -1,81 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class PendingAlbumUpload {
final LocalAsset asset;
final double progress;
final bool failed;
const PendingAlbumUpload({required this.asset, this.progress = 0.0, this.failed = false});
PendingAlbumUpload copyWith({double? progress, bool? failed}) =>
PendingAlbumUpload(asset: asset, progress: progress ?? this.progress, failed: failed ?? this.failed);
}
class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier<List<PendingAlbumUpload>, String> {
KeepAliveLink? _keepAliveLink;
@override
List<PendingAlbumUpload> build(String albumId) {
ref.onDispose(() {
_keepAliveLink?.close();
_keepAliveLink = null;
});
return const [];
}
void enqueue(Iterable<LocalAsset> assets) {
if (assets.isEmpty) {
return;
}
final existingIds = state.map((e) => e.asset.id).toSet();
final additions = assets.where((a) => !existingIds.contains(a.id)).map((a) => PendingAlbumUpload(asset: a));
state = [...state, ...additions];
_syncKeepAlive();
}
void updateProgress(String localAssetId, double progress) {
state = [
for (final entry in state)
if (entry.asset.id == localAssetId) entry.copyWith(progress: progress, failed: false) else entry,
];
_syncKeepAlive();
}
void markFailed(String localAssetId) {
state = [
for (final entry in state)
if (entry.asset.id == localAssetId) entry.copyWith(failed: true) else entry,
];
_syncKeepAlive();
}
void markAllFailed() {
state = [for (final entry in state) entry.copyWith(failed: true)];
_syncKeepAlive();
}
void remove(String localAssetId) {
state = state.where((e) => e.asset.id != localAssetId).toList();
_syncKeepAlive();
}
void clearFailed() {
state = state.where((e) => !e.failed).toList();
_syncKeepAlive();
}
void _syncKeepAlive() {
if (state.isEmpty) {
_keepAliveLink?.close();
_keepAliveLink = null;
} else {
_keepAliveLink ??= ref.keepAlive();
}
}
}
final pendingAlbumUploadsProvider = NotifierProvider.autoDispose
.family<AlbumPendingUploadsNotifier, List<PendingAlbumUpload>, String>(AlbumPendingUploadsNotifier.new);
@@ -5,15 +5,16 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden } enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden }
@@ -107,7 +108,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
final backgroundManager = _ref.read(backgroundSyncProvider); final backgroundManager = _ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = _ref.read(metadataProvider).appConfig.backup.syncAlbums; final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
try { try {
bool syncSuccess = false; bool syncSuccess = false;
@@ -137,7 +138,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
Future<void> _resumeBackup() async { Future<void> _resumeBackup() async {
final isEnableBackup = _ref.read(metadataProvider).appConfig.backup.enabled; final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (isEnableBackup) { if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser); final currentUser = Store.tryGet(StoreKey.currentUser);
+5 -10
View File
@@ -1,9 +1,6 @@
import 'dart:convert';
import 'package:flutter_udid/flutter_udid.dart'; import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart';
@@ -11,7 +8,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/auth.service.dart';
@@ -130,8 +126,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _apiService.updateHeaders(); await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders; final customHeaders = Store.tryGet(StoreKey.customHeaders);
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders); await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
// Get the deviceid from the store if it exists, otherwise generate a new one // Get the deviceid from the store if it exists, otherwise generate a new one
@@ -179,19 +174,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
} }
Future<void> saveWifiName(String wifiName) async { Future<void> saveWifiName(String wifiName) async {
await _ref.read(metadataProvider).write(MetadataKey.networkPreferredWifiName, wifiName); await Store.put(StoreKey.preferredWifiName, wifiName);
} }
Future<void> saveLocalEndpoint(String url) async { Future<void> saveLocalEndpoint(String url) async {
await _ref.read(metadataProvider).write(MetadataKey.networkLocalEndpoint, url); await Store.put(StoreKey.localEndpoint, url);
} }
String? getSavedWifiName() { String? getSavedWifiName() {
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName; return Store.tryGet(StoreKey.preferredWifiName);
} }
String? getSavedLocalEndpoint() { String? getSavedLocalEndpoint() {
return _ref.read(metadataProvider).systemConfig.network.localEndpoint; return Store.tryGet(StoreKey.localEndpoint);
} }
/// Returns the current server endpoint (with /api) URL from the store /// Returns the current server endpoint (with /api) URL from the store
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -354,23 +353,6 @@ class ActionNotifier extends Notifier<void> {
} }
} }
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
final count = await _service.tagAssets(ids, context);
if (count == null) {
return null;
}
ref.invalidate(tagProvider);
return ActionResult(count: count, success: true);
} catch (error, stack) {
_logger.severe('Failed to tag assets', error, stack);
ref.invalidate(tagProvider);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async { Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source); final ids = _getRemoteIdsForSource(source);
try { try {
@@ -9,7 +9,6 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
final localAlbumRepository = Provider<DriftLocalAlbumRepository>( final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
@@ -34,11 +33,7 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
); );
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>( final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
(ref) => RemoteAlbumService( (ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(foregroundUploadServiceProvider),
),
dependencies: [remoteAlbumRepository], dependencies: [remoteAlbumRepository],
); );
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
@@ -8,10 +6,8 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
class RemoteAlbumState { class RemoteAlbumState {
@@ -109,46 +105,6 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
} }
} }
/// Creates an album from a heterogeneous asset selection. Already-remote
/// assets seed the album immediately; local-only assets are uploaded in the
/// background and linked one-by-one as each upload completes.
Future<RemoteAlbum?> createAlbumWithAssets({
required String title,
String? description,
Iterable<BaseAsset> assets = const [],
}) async {
try {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw Exception('User not logged in');
}
final candidates = RemoteAlbumService.categorizeCandidates(assets);
final album = await _remoteAlbumService.createAlbum(
title: title,
owner: currentUser,
description: description,
assetIds: candidates.remoteAssetIds,
);
state = state.copyWith(albums: [...state.albums, album]);
if (candidates.localAssetsToUpload.isNotEmpty) {
unawaited(
addAssetsToAlbum(
album.id,
candidates.localAssetsToUpload,
).then<void>((_) {}).catchError((Object _, StackTrace _) {}),
);
}
return album;
} catch (error, stack) {
_logger.severe('Failed to create album with assets', error, stack);
rethrow;
}
}
Future<RemoteAlbum?> updateAlbum( Future<RemoteAlbum?> updateAlbum(
String albumId, { String albumId, {
String? name, String? name,
@@ -199,65 +155,8 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return _remoteAlbumService.getAssets(albumId); return _remoteAlbumService.getAssets(albumId);
} }
Future<int> addAssets(String albumId, List<String> assetIds) async { Future<int> addAssets(String albumId, List<String> assetIds) {
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds); return _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (added > 0) {
await _refreshAlbumInState(albumId);
}
return added;
}
/// Adds a heterogeneous asset selection to an album. Already-remote assets
/// are linked immediately; local-only assets are queued in
/// [pendingAlbumUploadsProvider] (so the album page can show them with
/// progress indicators), uploaded, and linked one-by-one as each finishes.
Future<int> addAssetsToAlbum(String albumId, Iterable<BaseAsset> assets) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw Exception('User not logged in');
}
final candidates = RemoteAlbumService.categorizeCandidates(assets);
final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier);
pendingNotifier.enqueue(candidates.localAssetsToUpload);
try {
final added = await _remoteAlbumService.addAssetsToAlbum(
albumId: albumId,
uploader: currentUser,
candidates: candidates,
uploadCallbacks: UploadCallbacks(
onProgress: (localAssetId, _, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
pendingNotifier.updateProgress(localAssetId, progress);
},
onSuccess: (localAssetId, _) => pendingNotifier.remove(localAssetId),
onError: (localAssetId, _) => pendingNotifier.markFailed(localAssetId),
),
);
if (added > 0) {
await _refreshAlbumInState(albumId);
}
return added;
} catch (error, stack) {
if (candidates.localAssetsToUpload.isNotEmpty) {
pendingNotifier.markAllFailed();
}
_logger.severe('Failed to add assets to album $albumId', error, stack);
rethrow;
}
}
/// Re-reads a single album from the local DB and replaces it in [state] so
/// that views bound to the album list (counts, thumbnails) reflect the
/// latest junction-table changes without a full `refresh()`.
Future<void> _refreshAlbumInState(String albumId) async {
final updated = await _remoteAlbumService.get(albumId);
if (updated == null) {
return;
}
state = state.copyWith(albums: state.albums.map((album) => album.id == albumId ? updated : album).toList());
} }
Future<void> addUsers(String albumId, List<String> userIds) { Future<void> addUsers(String albumId, List<String> userIds) {
@@ -1,22 +1,16 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart'; import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
class TagNotifier extends AsyncNotifier<Set<Tag>> { class TagNotifier extends AsyncNotifier<Set<Tag>> {
@override @override
Future<Set<Tag>> build() async { Future<Set<Tag>> build() async {
return ref.watch(tagServiceProvider).getAllTags(); final repo = ref.read(tagsApiRepositoryProvider);
} final allTags = await repo.getAllTags();
if (allTags == null) {
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async { return {};
return ref.read(tagServiceProvider).bulkTagAssets(assetIds, tagIds); }
} return allTags.map((t) => Tag.fromDto(t)).toSet();
Future<List<Tag>> upsertTags(List<String> tags) async {
final upsertedTags = await ref.read(tagServiceProvider).upsertTags(tags);
state = AsyncValue.data({...?state.valueOrNull, ...upsertedTags});
return upsertedTags;
} }
} }
+2 -3
View File
@@ -7,7 +7,6 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar
import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
@@ -193,7 +192,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
return; return;
} }
final isSyncAlbumEnabled = _ref.read(metadataProvider).appConfig.backup.syncAlbums; final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
try { try {
unawaited( unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) { _ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) {
@@ -214,7 +213,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
return; return;
} }
final isSyncAlbumEnabled = _ref.read(metadataProvider).appConfig.backup.syncAlbums; final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
try { try {
unawaited( unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) { _ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) {
+19 -13
View File
@@ -1,40 +1,46 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
final authRepositoryProvider = Provider<AuthRepository>( final authRepositoryProvider = Provider<AuthRepository>((ref) => AuthRepository(ref.watch(driftProvider)));
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
);
class AuthRepository { class AuthRepository {
final Drift _drift; final Drift _drift;
final MetadataRepository _metadata;
const AuthRepository(this._drift, this._metadata); const AuthRepository(this._drift);
Future<void> clearLocalData() async { Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset(); await SyncStreamRepository(_drift).reset();
} }
bool getEndpointSwitchingFeature() { bool getEndpointSwitchingFeature() {
return _metadata.systemConfig.network.autoEndpointSwitching; return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
} }
String? getPreferredWifiName() { String? getPreferredWifiName() {
return _metadata.systemConfig.network.preferredWifiName; return Store.tryGet(StoreKey.preferredWifiName);
} }
String? getLocalEndpoint() { String? getLocalEndpoint() {
return _metadata.systemConfig.network.localEndpoint; return Store.tryGet(StoreKey.localEndpoint);
} }
List<AuxilaryEndpoint> getExternalEndpointList() { List<AuxilaryEndpoint> getExternalEndpointList() {
return _metadata.systemConfig.network.externalEndpointList final jsonString = Store.tryGet(StoreKey.externalEndpointList);
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
.toList(); if (jsonString == null) {
return [];
}
final List<dynamic> jsonList = jsonDecode(jsonString);
final endpointList = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
return endpointList;
} }
} }
-2
View File
@@ -60,7 +60,6 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
@@ -190,7 +189,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart // required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722 // auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'), RedirectRoute(path: '*', redirectTo: '/'),
-47
View File
@@ -1095,53 +1095,6 @@ class DriftSearchRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [DriftSlideshowPage]
class DriftSlideshowRoute extends PageRouteInfo<DriftSlideshowRouteArgs> {
DriftSlideshowRoute({
Key? key,
required TimelineService timeline,
List<PageRouteInfo>? children,
}) : super(
DriftSlideshowRoute.name,
args: DriftSlideshowRouteArgs(key: key, timeline: timeline),
initialChildren: children,
);
static const String name = 'DriftSlideshowRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftSlideshowRouteArgs>();
return DriftSlideshowPage(key: args.key, timeline: args.timeline);
},
);
}
class DriftSlideshowRouteArgs {
const DriftSlideshowRouteArgs({this.key, required this.timeline});
final Key? key;
final TimelineService timeline;
@override
String toString() {
return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DriftSlideshowRouteArgs) return false;
return key == other.key && timeline == other.timeline;
}
@override
int get hashCode => key.hashCode ^ timeline.hashCode;
}
/// generated route for /// generated route for
/// [DriftTrashPage] /// [DriftTrashPage]
class DriftTrashRoute extends PageRouteInfo<void> { class DriftTrashRoute extends PageRouteInfo<void> {
-25
View File
@@ -7,7 +7,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -24,7 +23,6 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
final actionServiceProvider = Provider<ActionService>( final actionServiceProvider = Provider<ActionService>(
@@ -37,7 +35,6 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(trashedLocalAssetRepository), ref.watch(trashedLocalAssetRepository),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider), ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
), ),
); );
@@ -50,7 +47,6 @@ class ActionService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository; final DownloadRepository _downloadRepository;
final TagService _tagService;
const ActionService( const ActionService(
this._assetApiRepository, this._assetApiRepository,
@@ -61,7 +57,6 @@ class ActionService {
this._trashedLocalAssetRepository, this._trashedLocalAssetRepository,
this._assetMediaRepository, this._assetMediaRepository,
this._downloadRepository, this._downloadRepository,
this._tagService,
); );
Future<void> shareLink(List<String> remoteIds, BuildContext context) async { Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@@ -239,26 +234,6 @@ class ActionService {
return true; return true;
} }
Future<int?> tagAssets(List<String> remoteIds, BuildContext context) async {
final tagResults = await showTagPickerModal(context: context);
if (tagResults == null) {
// user cancelled
return null;
}
final selectedTagIds = Set<String>.from(tagResults.$1);
final selectedNewTagValues = tagResults.$2;
if (selectedNewTagValues.isNotEmpty) {
final upsertedTags = await _tagService.upsertTags(selectedNewTagValues.toList());
selectedTagIds.addAll(upsertedTags.map((t) => t.id));
}
if (selectedTagIds.isEmpty) {
return 0;
}
return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList());
}
Future<void> stack(String userId, List<String> remoteIds) async { Future<void> stack(String userId, List<String> remoteIds) async {
final stack = await _assetApiRepository.stack(remoteIds); final stack = await _assetApiRepository.stack(remoteIds);
await _remoteAssetRepository.stack(userId, stack); await _remoteAssetRepository.stack(userId, stack);
+17 -8
View File
@@ -5,8 +5,8 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -177,21 +177,30 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) { if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint); urls.add(serverEndpoint);
} }
final network = MetadataRepository.instance.systemConfig.network; final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
final localEndpoint = network.localEndpoint; if (localEndpoint != null && localEndpoint.isNotEmpty) {
if (localEndpoint != null) {
urls.add(localEndpoint); urls.add(localEndpoint);
} }
for (final url in network.externalEndpointList) { final externalJson = Store.tryGet(StoreKey.externalEndpointList);
if (url.isNotEmpty) { if (externalJson != null) {
urls.add(url); final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
} }
} }
return urls; return urls;
} }
static Map<String, String> getRequestHeaders() { static Map<String, String> getRequestHeaders() {
return MetadataRepository.instance.systemConfig.network.customHeaders; var customHeadersStr = Store.get(StoreKey.customHeaders, "");
if (customHeadersStr.isEmpty) {
return const {};
}
return (jsonDecode(customHeadersStr) as Map).cast<String, String>();
} }
ApiClient get apiClient => _apiClient; ApiClient get apiClient => _apiClient;
+11 -1
View File
@@ -2,10 +2,20 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> { enum AppSettingsEnum<T> {
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false), manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true), enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); syncAlbums<bool>(StoreKey.syncAlbums, null, false),
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
+10 -3
View File
@@ -1,19 +1,19 @@
import 'dart:async'; import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/network.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -25,6 +25,7 @@ final authServiceProvider = Provider(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(networkServiceProvider), ref.watch(networkServiceProvider),
ref.watch(backgroundSyncProvider), ref.watch(backgroundSyncProvider),
ref.watch(appSettingsServiceProvider),
), ),
); );
@@ -34,6 +35,7 @@ class AuthService {
final ApiService _apiService; final ApiService _apiService;
final NetworkService _networkService; final NetworkService _networkService;
final BackgroundSyncManager _backgroundSyncManager; final BackgroundSyncManager _backgroundSyncManager;
final AppSettingsService _appSettingsService;
final _log = Logger("AuthService"); final _log = Logger("AuthService");
AuthService( AuthService(
@@ -42,6 +44,7 @@ class AuthService {
this._apiService, this._apiService,
this._networkService, this._networkService,
this._backgroundSyncManager, this._backgroundSyncManager,
this._appSettingsService,
); );
/// Validates the provided server URL by resolving and setting the endpoint. /// Validates the provided server URL by resolving and setting the endpoint.
@@ -100,7 +103,7 @@ class AuthService {
_log.severe("Error clearing local data", error, stackTrace); _log.severe("Error clearing local data", error, stackTrace);
}); });
await MetadataRepository.instance.write(MetadataKey.backupEnabled, false); await _appSettingsService.setSetting(AppSettingsEnum.enableBackup, false);
} }
} }
@@ -120,6 +123,10 @@ class AuthService {
_authRepository.clearLocalData(), _authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint),
Store.delete(StoreKey.externalEndpointList),
]); ]);
} }
@@ -13,13 +13,14 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -30,6 +31,7 @@ final backgroundUploadServiceProvider = Provider((ref) {
ref.watch(storageRepositoryProvider), ref.watch(storageRepositoryProvider),
ref.watch(localAssetRepository), ref.watch(localAssetRepository),
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
); );
@@ -103,6 +105,7 @@ class BackgroundUploadService {
this._storageRepository, this._storageRepository,
this._localAssetRepository, this._localAssetRepository,
this._backupRepository, this._backupRepository,
this._appSettingsService,
this._assetMediaRepository, this._assetMediaRepository,
) { ) {
_uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onUploadStatus = _onUploadCallback;
@@ -113,6 +116,7 @@ class BackgroundUploadService {
final StorageRepository _storageRepository; final StorageRepository _storageRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftBackupRepository _backupRepository; final DriftBackupRepository _backupRepository;
final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final Logger _logger = Logger('BackgroundUploadService'); final Logger _logger = Logger('BackgroundUploadService');
@@ -359,14 +363,15 @@ class BackgroundUploadService {
} }
bool _shouldRequireWiFi(LocalAsset asset) { bool _shouldRequireWiFi(LocalAsset asset) {
final backup = MetadataRepository.instance.appConfig.backup; bool requiresWiFi = true;
if (asset.isVideo && backup.useCellularForVideos) {
return false; if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
requiresWiFi = false;
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
requiresWiFi = false;
} }
if (!asset.isVideo && backup.useCellularForPhotos) {
return false; return requiresWiFi;
}
return true;
} }
Future<UploadTask> buildUploadTask( Future<UploadTask> buildUploadTask(
@@ -7,17 +7,18 @@ import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -38,6 +39,7 @@ final foregroundUploadServiceProvider = Provider((ref) {
ref.watch(storageRepositoryProvider), ref.watch(storageRepositoryProvider),
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider), ref.watch(connectivityApiProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
); );
}); });
@@ -53,6 +55,7 @@ class ForegroundUploadService {
this._storageRepository, this._storageRepository,
this._backupRepository, this._backupRepository,
this._connectivityApi, this._connectivityApi,
this._appSettingsService,
this._assetMediaRepository, this._assetMediaRepository,
); );
@@ -60,6 +63,7 @@ class ForegroundUploadService {
final StorageRepository _storageRepository; final StorageRepository _storageRepository;
final DriftBackupRepository _backupRepository; final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi; final ConnectivityApi _connectivityApi;
final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final Logger _logger = Logger('ForegroundUploadService'); final Logger _logger = Logger('ForegroundUploadService');
@@ -451,13 +455,14 @@ class ForegroundUploadService {
} }
bool _shouldRequireWiFi(LocalAsset asset) { bool _shouldRequireWiFi(LocalAsset asset) {
final backup = MetadataRepository.instance.appConfig.backup; bool requiresWiFi = true;
if (asset.isVideo && backup.useCellularForVideos) {
return false; if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
requiresWiFi = false;
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
requiresWiFi = false;
} }
if (!asset.isVideo && backup.useCellularForPhotos) {
return false; return requiresWiFi;
}
return true;
} }
} }
@@ -27,7 +27,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_pi
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
@@ -74,7 +73,6 @@ enum ActionButtonType {
similarPhotos, similarPhotos,
setProfilePicture, setProfilePicture,
viewInTimeline, viewInTimeline,
slideshow,
download, download,
upload, upload,
openInBrowser, openInBrowser,
@@ -181,7 +179,6 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.localAlbum && context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner, context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote, ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.slideshow => true,
}; };
} }
@@ -203,7 +200,6 @@ enum ActionButtonType {
iconOnly: iconOnly, iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
), ),
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unarchive => UnArchiveActionButton( ActionButtonType.unarchive => UnArchiveActionButton(
source: context.source, source: context.source,
+3 -2
View File
@@ -43,8 +43,9 @@ void configureFileDownloaderNotifications() {
abstract final class Bootstrap { abstract final class Bootstrap {
static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async { static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async {
final drift = Drift(); await configureSqliteCache();
final logDb = DriftLogger(); final drift = Drift.sqlite(await openSqliteConnection(name: 'immich'));
final logDb = DriftLogger.sqlite(await openSqliteConnection(name: 'immich_logs'));
final DriftStoreRepository storeRepo = DriftStoreRepository(drift); final DriftStoreRepository storeRepo = DriftStoreRepository(drift);
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates); await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
+12 -123
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -13,8 +12,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26; const int targetVersion = 26;
@@ -39,35 +37,12 @@ Future<void> _migrateTo25() async {
return; return;
} }
final urls = <String>[]; final serverUrls = ApiService.getServerUrls();
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint); if (serverUrls.isEmpty) {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final localEndpoint = Store.tryGet(StoreKey.legacyLocalEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.legacyExternalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
}
}
if (urls.isEmpty) {
return; return;
} }
final customHeadersStr = Store.get(StoreKey.legacyCustomHeaders, ""); await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
final headers = customHeadersStr.isEmpty
? const <String, String>{}
: (jsonDecode(customHeadersStr) as Map).cast<String, String>();
await NetworkRepository.setHeaders(headers, urls, token: accessToken);
} }
Future<void> _migrateTo26(Drift drift) async { Future<void> _migrateTo26(Drift drift) async {
@@ -82,7 +57,14 @@ Future<void> _migrateTo26(Drift drift) async {
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id); final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id);
if (cleanupKeepAlbumIds != null) { if (cleanupKeepAlbumIds != null) {
final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList(); final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList();
migrator.stage(StoreKey.legacyCleanupKeepAlbumIds, MetadataKey.cleanupKeepAlbumIds, ids); await drift.metadataEntity.insertOnConflictUpdate(
MetadataEntityCompanion.insert(
key: MetadataKey.cleanupKeepAlbumIds.key,
value: MetadataKey.cleanupKeepAlbumIds.encode(ids),
updatedAt: Value(DateTime.now()),
),
);
await migrator.deleteLegacyStoreRows([StoreKey.legacyCleanupKeepAlbumIds.id]);
} }
await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, MetadataKey.cleanupKeepFavorites); await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, MetadataKey.cleanupKeepFavorites);
await migrator.migrateEnumIndex( await migrator.migrateEnumIndex(
@@ -114,87 +96,9 @@ Future<void> _migrateTo26(Drift drift) async {
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo); await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo);
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo); await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo);
await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate); await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate);
// Network
await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, MetadataKey.networkAutoEndpointSwitching);
await migrator.migrateString(StoreKey.legacyPreferredWifiName, MetadataKey.networkPreferredWifiName);
await migrator.migrateString(StoreKey.legacyLocalEndpoint, MetadataKey.networkLocalEndpoint);
await _migrateExternalEndpointList(migrator);
await _migrateCustomHeaders(migrator);
// Album
await _migrateAlbumSortMode(migrator);
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, MetadataKey.albumIsReverse);
await migrator.migrateBool(StoreKey.legacyAlbumGridView, MetadataKey.albumIsGrid);
// Backup
await migrator.migrateBool(StoreKey.legacyEnableBackup, MetadataKey.backupEnabled);
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadVideos, MetadataKey.backupUseCellularForVideos);
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadPhotos, MetadataKey.backupUseCellularForPhotos);
await migrator.migrateBool(StoreKey.legacyBackupRequireCharging, MetadataKey.backupRequireCharging);
await migrator.migrateInt(StoreKey.legacyBackupTriggerDelay, MetadataKey.backupTriggerDelay);
await migrator.migrateBool(StoreKey.legacySyncAlbums, MetadataKey.backupSyncAlbums);
await migrator.complete(); await migrator.complete();
} }
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
if (raw == null) {
return;
}
final mode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == raw,
orElse: () => MetadataKey.albumSortMode.defaultValue,
);
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
}
Future<void> _migrateExternalEndpointList(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id);
if (raw == null) {
return;
}
final urls = <String>[];
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
for (final entry in decoded) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
}
}
} on FormatException {
// ignore invalid entries
}
migrator.stage(StoreKey.legacyExternalEndpointList, MetadataKey.networkExternalEndpointList, urls);
}
Future<void> _migrateCustomHeaders(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id);
if (raw == null) {
return;
}
final headers = <String, String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
decoded.forEach((key, value) {
if (key is String && value is String) {
headers[key] = value;
}
});
}
} on FormatException {
// ignore invalid entries
}
migrator.stage(StoreKey.legacyCustomHeaders, MetadataKey.networkCustomHeaders, headers);
}
class _StoreMigrator { class _StoreMigrator {
final Drift _db; final Drift _db;
final Map<MetadataKey<Object>, Object> _cache = {}; final Map<MetadataKey<Object>, Object> _cache = {};
@@ -249,21 +153,6 @@ class _StoreMigrator {
_migratedStoreIds.add(legacyKey.id); _migratedStoreIds.add(legacyKey.id);
} }
Future<void> migrateString(StoreKey<String> legacyKey, MetadataKey<String> newKey) async {
final value = await readLegacyStoreString(legacyKey.id);
if (value == null) {
return;
}
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
void stage<T extends Object>(StoreKey legacyKey, MetadataKey<T> newKey, T value) {
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
Future<void> complete() async { Future<void> complete() async {
await _db.batch((batch) { await _db.batch((batch) {
for (final entry in _cache.entries) { for (final entry in _cache.entries) {
-11
View File
@@ -24,17 +24,6 @@ String? getServerUrl() {
); );
} }
String? buildSharedLinkUrl({required String? baseUrl, required String key, String? slug}) {
if (baseUrl == null || baseUrl.isEmpty) {
return null;
}
final normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final path = (slug != null && slug.isNotEmpty) ? 's/$slug' : 'share/$key';
return '$normalizedBaseUrl$path';
}
/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode). /// Converts a Unicode URL to its ASCII-compatible encoding (Punycode).
/// ///
/// This is especially useful for internationalized domain names (IDNs), /// This is especially useful for internationalized domain names (IDNs),
@@ -6,12 +6,13 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -192,51 +193,64 @@ class _BackupIndicator extends ConsumerWidget {
} }
Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) { Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled)); final backupStateStream = ref.watch(settingsProvider).watch(Setting.enableBackup);
final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none)); final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
final isDarkTheme = context.isDarkTheme; final isDarkTheme = context.isDarkTheme;
final iconColor = isDarkTheme ? Colors.white : Colors.black; final iconColor = isDarkTheme ? Colors.white : Colors.black;
final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
if (!backupEnabled) { return StreamBuilder(
return _BadgeLabel( stream: backupStateStream,
Icon(Icons.cloud_off_rounded, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()), initialData: false,
); builder: (ctx, snapshot) {
} final backupEnabled = snapshot.data ?? false;
if (hasError) { if (!backupEnabled) {
return _BadgeLabel( return _BadgeLabel(
Icon( Icon(
Icons.warning_rounded, Icons.cloud_off_rounded,
size: 12, size: 9,
color: context.colorScheme.error, color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(), semanticLabel: 'backup_controller_page_backup'.tr(),
),
backgroundColor: context.colorScheme.errorContainer,
);
}
if (isUploading) {
return _BadgeLabel(
Container(
padding: const EdgeInsets.all(3.5),
child: Theme(
data: context.themeData.copyWith(
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
), ),
child: CircularProgressIndicator( );
strokeWidth: 2, }
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
semanticsLabel: 'backup_controller_page_backup'.tr(),
),
),
),
);
}
return _BadgeLabel( if (hasError) {
Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()), return _BadgeLabel(
Icon(
Icons.warning_rounded,
size: 12,
color: context.colorScheme.error,
semanticLabel: 'backup_controller_page_backup'.tr(),
),
backgroundColor: context.colorScheme.errorContainer,
);
}
if (isUploading) {
return _BadgeLabel(
Container(
padding: const EdgeInsets.all(3.5),
child: Theme(
data: context.themeData.copyWith(
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
),
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
semanticsLabel: 'backup_controller_page_backup'.tr(),
),
),
),
);
}
return _BadgeLabel(
Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
);
},
); );
} }
} }
@@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart'; import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
@@ -90,10 +89,6 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
onPressed: () => context.maybePop(), onPressed: () => context.maybePop(),
), ),
actions: [ actions: [
IconButton(
onPressed: () => context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))),
icon: Icon(Icons.slideshow_outlined, color: actionIconColor, shadows: actionIconShadows),
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared) if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton( IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows), icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
+5 -112
View File
@@ -8,78 +8,12 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/common/search_field.dart';
String _trimSlashes(String s) => s.replaceAll(RegExp(r'^/+|/+$'), '');
Future<(Set<String>, Set<String>)?> showTagPickerModal({required BuildContext context, Set<String>? initialSelection}) {
return showDialog<(Set<String>, Set<String>)?>(
context: context,
builder: (context) => _TagPickerModal(initialSelection: initialSelection),
);
}
class _TagPickerModal extends HookConsumerWidget {
final Set<String>? initialSelection;
const _TagPickerModal({this.initialSelection});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedTagIds = useState<Set<String>>(initialSelection ?? {});
final newTagValues = useState<Set<String>>({});
void onSelectExistingTag(Iterable<Tag> tags) {
selectedTagIds.value = tags.map((tag) => tag.id).toSet();
}
void onSelectNewTag(Set<String> tags) {
newTagValues.value = tags;
}
return AlertDialog(
contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 0),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(
"cancel",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.error,
),
).tr(),
),
TextButton(
onPressed: () => context.pop((selectedTagIds.value, newTagValues.value)),
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
).tr(),
),
],
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
height: MediaQuery.of(context).size.height * 0.6,
child: TagPicker(
onSelectExistingTag: onSelectExistingTag,
filter: selectedTagIds.value,
onSelectNewTag: onSelectNewTag,
),
),
);
}
}
class TagPicker extends HookConsumerWidget { class TagPicker extends HookConsumerWidget {
const TagPicker({super.key, required this.onSelectExistingTag, required this.filter, this.onSelectNewTag}); const TagPicker({super.key, required this.onSelect, required this.filter});
final Function(Iterable<Tag>) onSelect;
final Set<String> filter; final Set<String> filter;
/// Callback when existing tags are selected/deselected.
final Function(Iterable<Tag>) onSelectExistingTag;
/// If not null, shows a tile to create a new tag with user's filter input.
final Function(Set<String>)? onSelectNewTag;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final formFocus = useFocusNode(); final formFocus = useFocusNode();
@@ -87,7 +21,6 @@ class TagPicker extends HookConsumerWidget {
final tags = ref.watch(tagProvider); final tags = ref.watch(tagProvider);
final selectedTagIds = useState<Set<String>>(filter); final selectedTagIds = useState<Set<String>>(filter);
final borderRadius = const BorderRadius.all(Radius.circular(10)); final borderRadius = const BorderRadius.all(Radius.circular(10));
final selectedNewTagValues = useState<Set<String>>({});
return Column( return Column(
children: [ children: [
@@ -108,53 +41,13 @@ class TagPicker extends HookConsumerWidget {
Expanded( Expanded(
child: tags.widgetWhen( child: tags.widgetWhen(
onData: (tags) { onData: (tags) {
final trimmedQuery = _trimSlashes(searchQuery.value);
final queryResult = tags final queryResult = tags
.where((t) => t.value.toLowerCase().contains(trimmedQuery.toLowerCase())) .where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList(); .toList();
final showCreateTile =
(onSelectNewTag != null) &&
trimmedQuery.isNotEmpty &&
!tags.any((t) => t.value.toLowerCase() == trimmedQuery.toLowerCase());
final isCreateSelected = selectedNewTagValues.value.contains(trimmedQuery);
return ListView.builder( return ListView.builder(
itemCount: queryResult.length + (showCreateTile ? 1 : 0), itemCount: queryResult.length,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (showCreateTile && index == queryResult.length) {
// Create new tag tile
return Padding(
padding: const EdgeInsets.only(bottom: 2.0),
child: Container(
decoration: BoxDecoration(
color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: ListTile(
title: Text(
trimmedQuery,
style: context.textTheme.bodyLarge?.copyWith(
color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
),
),
trailing: Icon(
Icons.add,
color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
),
onTap: () {
final newSelectedNewTagValues = {...selectedNewTagValues.value};
if (isCreateSelected) {
newSelectedNewTagValues.remove(trimmedQuery);
} else {
newSelectedNewTagValues.add(trimmedQuery);
}
selectedNewTagValues.value = newSelectedNewTagValues;
onSelectNewTag!.call(newSelectedNewTagValues);
},
),
),
);
}
final tag = queryResult[index]; final tag = queryResult[index];
final isSelected = selectedTagIds.value.any((id) => id == tag.id); final isSelected = selectedTagIds.value.any((id) => id == tag.id);
@@ -180,7 +73,7 @@ class TagPicker extends HookConsumerWidget {
newSelected.add(tag.id); newSelected.add(tag.id);
} }
selectedTagIds.value = newSelected; selectedTagIds.value = newSelected;
onSelectExistingTag(tags.where((t) => newSelected.contains(t.id))); onSelect(tags.where((t) => newSelected.contains(t.id)));
}, },
), ),
), ),
+14 -12
View File
@@ -15,7 +15,6 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
@@ -187,7 +186,7 @@ class LoginForm extends HookConsumerWidget {
await backgroundManager.syncRemote(); await backgroundManager.syncRemote();
await backgroundManager.hashAssets(); await backgroundManager.hashAssets();
if (MetadataRepository.instance.appConfig.backup.syncAlbums) { if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum(); await backgroundManager.syncLinkedAlbum();
} }
} }
@@ -398,16 +397,19 @@ class LoginForm extends HookConsumerWidget {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
ImmichForm( ImmichForm(
onSubmit: getServerAuthSettings,
submitText: 'next'.t(context: context), submitText: 'next'.t(context: context),
submitIcon: Icons.arrow_forward_rounded, submitIcon: Icons.arrow_forward_rounded,
builder: (_, form) => ImmichURLInput( onSubmit: getServerAuthSettings,
child: ImmichTextInput(
controller: serverEndpointController, controller: serverEndpointController,
label: 'login_form_endpoint_url'.t(context: context), label: 'login_form_endpoint_url'.t(context: context),
hintText: 'login_form_endpoint_hint'.t(context: context), hintText: 'login_form_endpoint_hint'.t(context: context),
validator: _validateUrl, validator: _validateUrl,
keyboardAction: .next, keyboardAction: TextInputAction.next,
onSubmit: (_) => form.submit(), keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
autoCorrect: false,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
), ),
), ),
ImmichTextButton( ImmichTextButton(
@@ -435,10 +437,10 @@ class LoginForm extends HookConsumerWidget {
), ),
if (isPasswordLoginEnable.value) if (isPasswordLoginEnable.value)
ImmichForm( ImmichForm(
onSubmit: login,
submitText: 'login'.t(context: context), submitText: 'login'.t(context: context),
submitIcon: Icons.login_rounded, submitIcon: Icons.login_rounded,
builder: (context, form) => Column( onSubmit: login,
child: Column(
spacing: ImmichSpacing.md, spacing: ImmichSpacing.md,
children: [ children: [
ImmichTextInput( ImmichTextInput(
@@ -449,7 +451,7 @@ class LoginForm extends HookConsumerWidget {
keyboardAction: TextInputAction.next, keyboardAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email], autofillHints: const [AutofillHints.email],
onSubmit: (_) => passwordFocusNode.requestFocus(), onSubmit: (_, _) => passwordFocusNode.requestFocus(),
), ),
ImmichPasswordInput( ImmichPasswordInput(
controller: passwordController, controller: passwordController,
@@ -457,17 +459,17 @@ class LoginForm extends HookConsumerWidget {
label: 'password'.t(context: context), label: 'password'.t(context: context),
hintText: 'login_form_password_hint'.t(context: context), hintText: 'login_form_password_hint'.t(context: context),
keyboardAction: TextInputAction.go, keyboardAction: TextInputAction.go,
onSubmit: (_) => form.submit(), onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
), ),
], ],
), ),
), ),
if (isOauthEnable.value) if (isOauthEnable.value)
ImmichForm( ImmichForm(
onSubmit: oAuthLogin,
submitText: oAuthButtonLabel.value, submitText: oAuthButtonLabel.value,
submitIcon: Icons.pin_outlined, submitIcon: Icons.pin_outlined,
builder: (context, _) => isPasswordLoginEnable.value onSubmit: oAuthLogin,
child: isPasswordLoginEnable.value
? Padding( ? Padding(
padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0), padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5), child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5),
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/slideshow_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class AssetViewerSettings extends StatelessWidget { class AssetViewerSettings extends StatelessWidget {
@@ -14,7 +13,6 @@ class AssetViewerSettings extends StatelessWidget {
const ImageViewerQualitySetting(), const ImageViewerQualitySetting(),
const ImageViewerTapToNavigateSetting(), const ImageViewerTapToNavigateSetting(),
const VideoViewerSettings(), const VideoViewerSettings(),
const SlideshowSettings(),
]; ];
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true); return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
@@ -1,123 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
class SlideshowSettings extends HookConsumerWidget {
const SlideshowSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final slideshow = ref.read(appConfigProvider).slideshow;
final useTransition = useState(slideshow.transition);
final useRepeat = useState(slideshow.repeat);
final useDuration = useState(slideshow.duration);
final useLook = useState(slideshow.look);
final useDirection = useState(slideshow.direction);
useValueChanged<bool, void>(useTransition.value, (_, __) {
ref.read(metadataProvider).write(.slideshowTransition, useTransition.value);
});
useValueChanged<bool, void>(useRepeat.value, (_, __) {
ref.read(metadataProvider).write(.slideshowRepeat, useRepeat.value);
});
useValueChanged<int, void>(useDuration.value, (_, __) {
ref.read(metadataProvider).write(.slideshowDuration, useDuration.value);
});
useValueChanged<SlideshowLook, void>(useLook.value, (_, __) {
ref.read(metadataProvider).write(.slideshowLook, useLook.value);
});
useValueChanged<SlideshowDirection, void>(useDirection.value, (_, __) {
ref.read(metadataProvider).write(.slideshowDirection, useDirection.value);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(
title: 'slideshow'.t(context: context),
icon: Icons.slideshow_outlined,
),
SettingsSwitchListTile(
valueNotifier: useTransition,
title: "show_slideshow_transition".t(context: context),
enabled: useDirection.value != SlideshowDirection.shuffle,
),
SettingsSwitchListTile(
valueNotifier: useRepeat,
title: "slideshow_repeat".t(context: context),
subtitle: "slideshow_repeat_description".t(context: context),
),
SettingsSliderListTile(
valueNotifier: useDuration,
text: "duration".t(context: context),
minValue: 5,
noDivisons: 5,
maxValue: 30,
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: SettingsSubTitle(title: 'look'.t(context: context)),
),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'contain'.t(context: context),
value: SlideshowLook.contain,
),
SettingsRadioGroup(
title: 'cover'.t(context: context),
value: SlideshowLook.cover,
),
SettingsRadioGroup(
title: 'blurred_background'.t(context: context),
value: SlideshowLook.blurredBackground,
),
],
groupBy: useLook.value,
onRadioChanged: (value) {
if (value != null) {
useLook.value = value;
}
},
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: SettingsSubTitle(title: 'direction'.t(context: context)),
),
Padding(
padding: const EdgeInsets.only(bottom: 32),
child: SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'forward'.t(context: context),
value: SlideshowDirection.forward,
),
SettingsRadioGroup(
title: 'backward'.t(context: context),
value: SlideshowDirection.backward,
),
SettingsRadioGroup(
title: 'shuffle'.t(context: context),
value: SlideshowDirection.shuffle,
),
],
groupBy: useDirection.value,
onRadioChanged: (value) {
if (value != null) {
useDirection.value = value;
}
},
),
),
],
);
}
}
@@ -4,17 +4,18 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.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/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart'; import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
@@ -30,8 +31,8 @@ class DriftBackupSettings extends ConsumerWidget {
title: "network_requirements".t(context: context), title: "network_requirements".t(context: context),
icon: Icons.cell_tower, icon: Icons.cell_tower,
), ),
const _UseCellularForVideosButton(), const _UseWifiForUploadVideosButton(),
const _UseCellularForPhotosButton(), const _UseWifiForUploadPhotosButton(),
if (CurrentPlatform.isAndroid) ...[ if (CurrentPlatform.isAndroid) ...[
const Divider(), const Divider(),
SettingGroupTitle( SettingGroupTitle(
@@ -98,58 +99,64 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final albumSyncEnable = ref.watch(appConfigProvider.select((c) => c.backup.syncAlbums));
return Padding( return Padding(
padding: const EdgeInsets.only(left: 8.0), padding: const EdgeInsets.only(left: 8.0),
child: ListView( child: ListView(
shrinkWrap: true, shrinkWrap: true,
children: [ children: [
Column( StreamBuilder(
children: [ stream: Store.watch(StoreKey.syncAlbums),
SettingListTile( initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
title: "sync_albums".t(context: context), builder: (context, snapshot) {
subtitle: "sync_upload_album_setting_subtitle".t(context: context), final albumSyncEnable = snapshot.data ?? false;
trailing: Switch( return Column(
value: albumSyncEnable, children: [
onChanged: (bool newValue) async { SettingListTile(
await ref.read(metadataProvider).write(MetadataKey.backupSyncAlbums, newValue); title: "sync_albums".t(context: context),
subtitle: "sync_upload_album_setting_subtitle".t(context: context),
trailing: Switch(
value: albumSyncEnable,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
if (newValue == true) { if (newValue == true) {
await _manageLinkedAlbums(); await _manageLinkedAlbums();
} }
}, },
), ),
), ),
AnimatedSize( AnimatedSize(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
opacity: albumSyncEnable ? 1.0 : 0.0, opacity: albumSyncEnable ? 1.0 : 0.0,
child: albumSyncEnable child: albumSyncEnable
? SettingListTile( ? SettingListTile(
onTap: _manualSyncAlbums, onTap: _manualSyncAlbums,
contentPadding: const EdgeInsets.only(left: 32, right: 16), contentPadding: const EdgeInsets.only(left: 32, right: 16),
title: "organize_into_albums".t(context: context), title: "organize_into_albums".t(context: context),
subtitle: "organize_into_albums_description".t(context: context), subtitle: "organize_into_albums_description".t(context: context),
trailing: isAlbumSyncInProgress trailing: isAlbumSyncInProgress
? const SizedBox( ? const SizedBox(
width: 32, width: 32,
height: 32, height: 32,
child: CircularProgressIndicator.adaptive(strokeWidth: 2), child: CircularProgressIndicator.adaptive(strokeWidth: 2),
) )
: IconButton( : IconButton(
onPressed: _manualSyncAlbums, onPressed: _manualSyncAlbums,
icon: const Icon(Icons.sync_rounded), icon: const Icon(Icons.sync_rounded),
color: context.colorScheme.onSurface.withValues(alpha: 0.7), color: context.colorScheme.onSurface.withValues(alpha: 0.7),
iconSize: 20, iconSize: 20,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
), ),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
), ),
], ],
);
},
), ),
], ],
), ),
@@ -157,34 +164,60 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
} }
} }
class _BackupSwitchTile extends ConsumerWidget { class _SettingsSwitchTile extends ConsumerStatefulWidget {
final MetadataKey<bool> metadataKey; final AppSettingsEnum<bool> appSettingsEnum;
final bool Function(AppConfig) selector;
final String titleKey; final String titleKey;
final String subtitleKey; final String subtitleKey;
final void Function(bool)? onChanged; final void Function(bool?)? onChanged;
const _BackupSwitchTile({ const _SettingsSwitchTile({
required this.metadataKey, required this.appSettingsEnum,
required this.selector,
required this.titleKey, required this.titleKey,
required this.subtitleKey, required this.subtitleKey,
this.onChanged, this.onChanged,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState createState() => _SettingsSwitchTileState();
final value = ref.watch(appConfigProvider.select(selector)); }
class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
late final Stream<bool?> valueStream;
late final StreamSubscription<bool?> subscription;
@override
void initState() {
super.initState();
valueStream = Store.watch(widget.appSettingsEnum.storeKey).asBroadcastStream();
subscription = valueStream.listen((value) {
widget.onChanged?.call(value);
});
}
@override
void dispose() {
subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 8.0), padding: const EdgeInsets.only(left: 8.0),
child: SettingListTile( child: SettingListTile(
title: titleKey.t(context: context), title: widget.titleKey.t(context: context),
subtitle: subtitleKey.t(context: context), subtitle: widget.subtitleKey.t(context: context),
trailing: Switch( trailing: StreamBuilder(
value: value, stream: valueStream,
onChanged: (bool newValue) async { initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
await ref.read(metadataProvider).write(metadataKey, newValue); builder: (context, snapshot) {
onChanged?.call(newValue); final value = snapshot.data ?? false;
return Switch(
value: value,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
},
);
}, },
), ),
), ),
@@ -192,28 +225,26 @@ class _BackupSwitchTile extends ConsumerWidget {
} }
} }
class _UseCellularForVideosButton extends StatelessWidget { class _UseWifiForUploadVideosButton extends ConsumerWidget {
const _UseCellularForVideosButton(); const _UseWifiForUploadVideosButton();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return _BackupSwitchTile( return const _SettingsSwitchTile(
metadataKey: MetadataKey.backupUseCellularForVideos, appSettingsEnum: AppSettingsEnum.useCellularForUploadVideos,
selector: (c) => c.backup.useCellularForVideos,
titleKey: "videos", titleKey: "videos",
subtitleKey: "network_requirement_videos_upload", subtitleKey: "network_requirement_videos_upload",
); );
} }
} }
class _UseCellularForPhotosButton extends StatelessWidget { class _UseWifiForUploadPhotosButton extends ConsumerWidget {
const _UseCellularForPhotosButton(); const _UseWifiForUploadPhotosButton();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return _BackupSwitchTile( return const _SettingsSwitchTile(
metadataKey: MetadataKey.backupUseCellularForPhotos, appSettingsEnum: AppSettingsEnum.useCellularForUploadPhotos,
selector: (c) => c.backup.useCellularForPhotos,
titleKey: "photos", titleKey: "photos",
subtitleKey: "network_requirement_photos_upload", subtitleKey: "network_requirement_photos_upload",
); );
@@ -225,22 +256,29 @@ class _BackupOnlyWhenChargingButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final fgService = ref.read(backgroundWorkerFgServiceProvider); return _SettingsSwitchTile(
return _BackupSwitchTile( appSettingsEnum: AppSettingsEnum.backupRequireCharging,
metadataKey: MetadataKey.backupRequireCharging,
selector: (c) => c.backup.requireCharging,
titleKey: "charging", titleKey: "charging",
subtitleKey: "charging_requirement_mobile_backup", subtitleKey: "charging_requirement_mobile_backup",
onChanged: (value) { onChanged: (value) {
fgService.configure(requireCharging: value); ref.read(backgroundWorkerFgServiceProvider).configure(requireCharging: value ?? false);
}, },
); );
} }
} }
class _BackupDelaySlider extends ConsumerWidget { class _BackupDelaySlider extends ConsumerStatefulWidget {
const _BackupDelaySlider(); const _BackupDelaySlider();
@override
ConsumerState<_BackupDelaySlider> createState() => _BackupDelaySliderState();
}
class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
late final Stream<int?> valueStream;
late final StreamSubscription<int?> subscription;
late int currentValue;
static int backupDelayToSliderValue(int ms) => switch (ms) { static int backupDelayToSliderValue(int ms) => switch (ms) {
5 => 0, 5 => 0,
30 => 1, 30 => 1,
@@ -263,9 +301,30 @@ class _BackupDelaySlider extends ConsumerWidget {
}; };
@override @override
Widget build(BuildContext context, WidgetRef ref) { void initState() {
final triggerDelay = ref.watch(appConfigProvider.select((c) => c.backup.triggerDelay)); super.initState();
final currentValue = backupDelayToSliderValue(triggerDelay); final initialValue =
Store.tryGet(AppSettingsEnum.backupTriggerDelay.storeKey) ?? AppSettingsEnum.backupTriggerDelay.defaultValue;
currentValue = backupDelayToSliderValue(initialValue);
valueStream = Store.watch(AppSettingsEnum.backupTriggerDelay.storeKey).asBroadcastStream();
subscription = valueStream.listen((value) {
if (mounted && value != null) {
setState(() {
currentValue = backupDelayToSliderValue(value);
});
}
});
}
@override
void dispose() {
subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -280,13 +339,14 @@ class _BackupDelaySlider extends ConsumerWidget {
), ),
Slider( Slider(
value: currentValue.toDouble(), value: currentValue.toDouble(),
onChanged: (double v) async { onChanged: (double v) {
final seconds = backupDelayToSeconds(v.toInt()); setState(() {
await ref.read(metadataProvider).write(MetadataKey.backupTriggerDelay, seconds); currentValue = v.toInt();
});
}, },
onChangeEnd: (double v) async { onChangeEnd: (double v) async {
final seconds = backupDelayToSeconds(v.toInt()); final milliseconds = backupDelayToSeconds(v.toInt());
await ref.read(metadataProvider).write(MetadataKey.backupTriggerDelay, seconds); await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.backupTriggerDelay, milliseconds);
}, },
max: 3.0, max: 3.0,
min: 0.0, min: 0.0,
@@ -1,10 +1,10 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_ui/immich_ui.dart';
class EndpointInput extends StatefulHookConsumerWidget { class EndpointInput extends StatefulHookConsumerWidget {
const EndpointInput({ const EndpointInput({
@@ -111,12 +111,28 @@ class EndpointInputState extends ConsumerState<EndpointInput> {
status: auxCheckStatus, status: auxCheckStatus,
enabled: widget.enabled, enabled: widget.enabled,
), ),
subtitle: ImmichURLInput( subtitle: TextFormField(
enabled: widget.enabled, enabled: widget.enabled,
autovalidateMode: .onUserInteraction, onTapOutside: (_) => focusNode.unfocus(),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: validateUrl, validator: validateUrl,
keyboardAction: .next, keyboardType: TextInputType.url,
hintText: 'http(s)://immich.domain.com', style: const TextStyle(fontFamily: 'GoogleSansCode', fontSize: 14),
decoration: InputDecoration(
hintText: 'http(s)://immich.domain.com',
contentPadding: const EdgeInsets.all(16),
filled: true,
fillColor: context.colorScheme.surfaceContainer,
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.red[300]!),
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: context.isDarkTheme ? Colors.grey[900]! : Colors.grey[300]!),
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
),
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
), ),
@@ -1,11 +1,13 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
class ExternalNetworkPreference extends HookConsumerWidget { class ExternalNetworkPreference extends HookConsumerWidget {
@@ -21,12 +23,11 @@ class ExternalNetworkPreference extends HookConsumerWidget {
saveEndpointList() { saveEndpointList() {
canSave.value = entries.value.every((e) => e.status == AuxCheckStatus.valid); canSave.value = entries.value.every((e) => e.status == AuxCheckStatus.valid);
final urls = entries.value final endpointList = entries.value.where((url) => url.status == AuxCheckStatus.valid).toList();
.where((e) => e.status == AuxCheckStatus.valid && e.url.isNotEmpty)
.map((e) => e.url)
.toList();
ref.read(metadataProvider).write(MetadataKey.networkExternalEndpointList, urls); final jsonString = jsonEncode(endpointList);
Store.put(StoreKey.externalEndpointList, jsonString);
} }
updateValidationStatus(String url, int index, AuxCheckStatus status) { updateValidationStatus(String url, int index, AuxCheckStatus status) {
@@ -68,13 +69,14 @@ class ExternalNetworkPreference extends HookConsumerWidget {
} }
useEffect(() { useEffect(() {
final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList; final jsonString = Store.tryGet(StoreKey.externalEndpointList);
if (urls.isEmpty) { if (jsonString == null) {
return null; return null;
} }
entries.value = urls.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList(); final List<dynamic> jsonList = jsonDecode(jsonString);
entries.value = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
return null; return null;
}, const []); }, const []);

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