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
355 changed files with 13653 additions and 16034 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:
+8 -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
@@ -288,6 +288,7 @@ jobs:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }} ENVIRONMENT: ${{ inputs.environment || 'development' }}
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
GITHUB_REF: ${{ github.ref }} GITHUB_REF: ${{ github.ref }}
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120 FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6 FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
+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 -30
View File
@@ -30,32 +30,25 @@ jobs:
filters: | filters: |
i18n: i18n:
- 'i18n/**' - 'i18n/**'
- 'mise.toml'
web: web:
- 'web/**' - 'web/**'
- 'i18n/**' - 'i18n/**'
- 'packages/sdk/**' - 'packages/sdk/**'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
- 'mise.toml'
server: server:
- 'server/**' - 'server/**'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
- 'mise.toml'
cli: cli:
- 'packages/cli/**' - 'packages/cli/**'
- 'packages/sdk/**' - 'packages/sdk/**'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
- 'mise.toml'
e2e: e2e:
- 'e2e/**' - 'e2e/**'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
- 'mise.toml'
mobile: mobile:
- 'mobile/**' - 'mobile/**'
- 'mise.toml'
machine-learning: machine-learning:
- 'machine-learning/**' - 'machine-learning/**'
- 'mise.toml'
.github: .github:
- '.github/**' - '.github/**'
force-filters: | force-filters: |
@@ -69,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
@@ -83,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
@@ -114,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 }}
@@ -145,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 }}
@@ -189,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 }}
@@ -227,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 }}
@@ -255,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 }}
@@ -305,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 }}
@@ -338,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 }}
@@ -384,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
@@ -557,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 }}
@@ -594,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 }}
@@ -625,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 }}
@@ -676,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
@@ -720,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
@@ -734,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
@@ -771,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
-65
View File
@@ -1,65 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.opentofu]]
version = "1.11.6"
backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
[tools.opentofu."platforms.linux-x64"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
[tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
[tools.opentofu."platforms.macos-x64"]
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
[tools.opentofu."platforms.windows-x64"]
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
[[tools.terragrunt]]
version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
[tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
[tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
[tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
[tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
[tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
[tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
+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:
+1 -1
View File
@@ -18,7 +18,7 @@ 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_:
- `pnpm install` - `pnpm install`
- `pnpm --filter @immich/sdk --filter @immich/cli build` - `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 -3
View File
@@ -10,6 +10,7 @@ const config = {
url: 'https://docs.immich.app', url: 'https://docs.immich.app',
baseUrl: '/', baseUrl: '/',
onBrokenLinks: 'throw', onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
favicon: 'img/favicon.png', favicon: 'img/favicon.png',
// GitHub pages deployment config. // GitHub pages deployment config.
@@ -28,9 +29,6 @@ const config = {
// Mermaid diagrams // Mermaid diagrams
markdown: { markdown: {
mermaid: true, mermaid: true,
hooks: {
onBrokenMarkdownLinks: 'warn',
},
}, },
themes: ['@docusaurus/theme-mermaid'], themes: ['@docusaurus/theme-mermaid'],
-5
View File
@@ -1,5 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]]
version = "4.66.0"
backend = "npm:wrangler"
+1 -1
View File
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}" run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools] [tools]
wrangler = "4.91.0" wrangler = "4.66.0"
+1 -1
View File
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^24.12.4", "@types/node": "^24.12.2",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
+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",
-72
View File
@@ -1,72 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.python]]
version = "3.11.15"
backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:243f794278eff6adba96ed3677ec6877175df84c25f140e17f09f9be82d0f12a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:52b4c52094ff8b383a45c694acf4c5c0e883152be6d5229a35a8186ce907c6eb"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:171dffd8c0f66e8a0725364a7428015b22fc18dd298b24f541392e17dd0e561f"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:2ac90fef8917ebd14826a6d667593a06cf0ae5f745ba9b1147dc086dd35f5284"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:fdfc363b538662eb7441a14e06f72c4a992c56af7f401f5730ea5081f8f8ad6e"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:5f1eb247cbca2c0ad5ccbf6d299a4f54b31b5c63b492d74c3531dc4344a42f88"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:756d7f148498b8822f6aedf44a020613576f09983161f346ad36dcef6238cdc3"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.uv]]
version = "0.8.15"
backend = "aqua:astral-sh/uv"
[tools.uv."platforms.linux-arm64"]
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.linux-arm64-musl"]
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.linux-x64"]
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.linux-x64-musl"]
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.macos-arm64"]
checksum = "sha256:103367962c5cb00bf7370d84cbaa3fec5a9807be9cc833ea9d8eea400c119fa2"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-apple-darwin.tar.gz"
[tools.uv."platforms.macos-x64"]
checksum = "sha256:2bbef70982e97dfc36454de173f35ec1a5e83ae11e3885df6a50db3fd76171cb"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-apple-darwin.tar.gz"
[tools.uv."platforms.windows-x64"]
checksum = "sha256:459d95892a5cc5c21779532f4f41b9238594b79e312a5142da2148ecfa10e705"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-pc-windows-msvc.zip"
-332
View File
@@ -1,332 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.41.9"
backend = "aqua:flutter/flutter"
[[tools.flutter]]
version = "3.41.9-stable"
backend = "asdf:flutter"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools."github:extism/cli"]]
version = "1.6.3"
backend = "github:extism/cli"
[tools."github:extism/cli"."platforms.linux-arm64"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
[tools."github:extism/cli"."platforms.linux-arm64-musl"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
[tools."github:extism/cli"."platforms.linux-x64"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
[tools."github:extism/cli"."platforms.linux-x64-musl"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
[tools."github:extism/cli"."platforms.macos-arm64"]
checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694029"
[tools."github:extism/cli"."platforms.macos-x64"]
checksum = "sha256:9a2f71b6e6009685a622cc3084e52d2a1a8e23c98d29ffa72e666e9dc699855f"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694026"
[tools."github:extism/cli"."platforms.windows-x64"]
checksum = "sha256:47e4ed2782445b2b08a4d1ac127211588f8b4d1fc25fd6481d4cb65151b5213c"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-windows-amd64.zip"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
[[tools."github:extism/js-pdk"]]
version = "1.6.0"
backend = "github:extism/js-pdk"
[tools."github:extism/js-pdk"."platforms.linux-arm64"]
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
[tools."github:extism/js-pdk"."platforms.linux-x64"]
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
[tools."github:extism/js-pdk"."platforms.macos-arm64"]
checksum = "sha256:548e25bda3971a07c32d78a249135cf8cb7b3eede101e878e06e53e01ac2e0ce"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-macos-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223215"
[tools."github:extism/js-pdk"."platforms.macos-x64"]
checksum = "sha256:d85a875c2a071f0c29fe572764c52c3a499f157ab7f9efac8939a4364390e29b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-macos-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223239"
[tools."github:extism/js-pdk"."platforms.windows-x64"]
checksum = "sha256:97b7b746141e4777e1ca2b76febdeb16dc9d314ff6a4257df05a476b67228acc"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-windows-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
[tools."github:webassembly/binaryen"."platforms.linux-arm64"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
[tools."github:webassembly/binaryen"."platforms.linux-x64"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
[tools."github:webassembly/binaryen"."platforms.macos-arm64"]
checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926134"
[tools."github:webassembly/binaryen"."platforms.macos-x64"]
checksum = "sha256:b389bb0731758d86c3cb266d01d28a12725c23bd3cabc3df34faa162af0887e9"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-macos.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926135"
[tools."github:webassembly/binaryen"."platforms.windows-x64"]
checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2203"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]]
version = "24.15.0"
backend = "core:node"
[tools.node."platforms.linux-arm64"]
checksum = "sha256:73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.gz"
[tools.node."platforms.linux-arm64-musl"]
checksum = "sha256:31e98aa960a067da91edffd5d93bc46657b5d2a8029612c359f5f2ac0060152a"
url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-arm64-musl.tar.gz"
[tools.node."platforms.linux-x64"]
checksum = "sha256:44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.gz"
[tools.node."platforms.linux-x64-musl"]
checksum = "sha256:f55af5bd489c5347b113ca6594cae00a54b30ba57ac5875324311bfc6f4762e3"
url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-x64-musl.tar.gz"
[tools.node."platforms.macos-arm64"]
checksum = "sha256:372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-arm64.tar.gz"
[tools.node."platforms.macos-x64"]
checksum = "sha256:ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-x64.tar.gz"
[tools.node."platforms.windows-x64"]
checksum = "sha256:cc5149eabd53779ce1e7bdc5401643622d0c7e6800ade18928a767e940bb0e62"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-win-x64.zip"
[[tools."npm:oazapfts"]]
version = "7.5.0"
backend = "npm:oazapfts"
[[tools.opentofu]]
version = "1.11.6"
backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
[tools.opentofu."platforms.linux-x64"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
[tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
[tools.opentofu."platforms.macos-x64"]
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
[tools.opentofu."platforms.windows-x64"]
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
[[tools.pnpm]]
version = "10.33.4"
backend = "aqua:pnpm/pnpm"
[[tools.terragrunt]]
version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
[tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
[tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
[tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
[tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
[tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
[tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
+5 -24
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",
@@ -16,28 +16,18 @@ config_roots = [
[tools] [tools]
node = "24.15.0" node = "24.15.0"
"aqua:flutter/flutter" = "3.41.9" flutter = "3.41.9"
pnpm = "10.33.4" pnpm = "10.33.1"
terragrunt = "1.0.3" 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"
@@ -50,13 +40,6 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[settings] [settings]
experimental = true experimental = true
pin = true pin = true
lockfile = 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 = [
@@ -72,13 +55,11 @@ 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" },
{ task = ":open-api-typescript" }, { task = ":open-api-typescript"},
{ task = ":open-api-dart" }, { task = ":open-api-dart"},
] ]
[tasks.sql] [tasks.sql]
-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
@@ -17,8 +17,6 @@ import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.permission.PermissionApi
import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26 import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30 import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -46,9 +44,7 @@ class MainActivity : FlutterFragmentActivity() {
} else { } else {
NativeSyncApiImpl30(ctx) NativeSyncApiImpl30(ctx)
} }
val permissionApiImpl = PermissionApiImpl(ctx)
NativeSyncApi.setUp(messenger, nativeSyncApiImpl) NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
PermissionApi.setUp(messenger, permissionApiImpl)
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx)) LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx)) RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
@@ -57,7 +53,6 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl) flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
} }
fun cancelPlugins(flutterEngine: FlutterEngine) { fun cancelPlugins(flutterEngine: FlutterEngine) {
@@ -65,8 +60,6 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin? flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin? ?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
nativeApi?.detachFromEngine() nativeApi?.detachFromEngine()
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
permissionApi?.detachFromEngine()
} }
} }
} }
@@ -315,7 +315,6 @@ interface NetworkApi {
fun hasCertificate(): Boolean fun hasCertificate(): Boolean
fun getClientPointer(): Long fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
fun getAppGroupId(): String
companion object { companion object {
/** The codec used by NetworkApi. */ /** The codec used by NetworkApi. */
@@ -431,21 +430,6 @@ interface NetworkApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getAppGroupId$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getAppGroupId())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }
@@ -13,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
private var networkApi: NetworkApiImpl? = null private var networkApi: NetworkApiImpl? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
networkApi = NetworkApiImpl(binding.applicationContext) networkApi = NetworkApiImpl()
NetworkApi.setUp(binding.binaryMessenger, networkApi) NetworkApi.setUp(binding.binaryMessenger, networkApi)
} }
@@ -39,11 +39,9 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
} }
} }
private class NetworkApiImpl(private val context: Context) : NetworkApi { private class NetworkApiImpl : NetworkApi {
var activity: Activity? = null var activity: Activity? = null
override fun getAppGroupId(): String = context.packageName
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) { override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
try { try {
HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray()) HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray())
@@ -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,96 +0,0 @@
package app.alextran.immich.permission
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.MediaStore
import android.provider.Settings
import androidx.core.net.toUri
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
class ManageMediaPermissionDelegate(
context: Context,
private val requestCode: Int = 1003,
) : PluginRegistry.ActivityResultListener {
private val ctx = context.applicationContext
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(ctx)
} else {
false
}
}
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
if (hasManageMediaPermission()) {
callback(Result.success(true))
return
}
openManageMediaPermissionSettings(callback)
}
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
openManageMediaPermissionSettings(callback)
}
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
callback(Result.success(false))
return
}
val activity = activityBinding?.activity
if (activity == null) {
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
return
}
pendingResult = callback
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
data = "package:${activity.packageName}".toUri()
}
try {
activity.startActivityForResult(intent, requestCode)
} catch (e: Exception) {
pendingResult = null
callback(
Result.failure(
FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
)
)
}
}
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
fun onDetachedFromActivity() {
failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == this.requestCode) {
val callback = pendingResult
pendingResult = null
callback?.invoke(Result.success(hasManageMediaPermission()))
return true
}
return false
}
private fun failPending(code: String, message: String) {
val callback = pendingResult ?: return
pendingResult = null
callback(Result.failure(FlutterError(code, message, null)))
}
}
@@ -1,128 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.permission
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object PermissionApiPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi {
fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
companion object {
/** The codec used by PermissionApi. */
val codec: MessageCodec<Any?> by lazy {
PermissionApiPigeonCodec()
}
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasManageMediaPermission())
} catch (exception: Throwable) {
PermissionApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.requestManageMediaPermission{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PermissionApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.manageMediaPermission{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PermissionApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -1,37 +0,0 @@
package app.alextran.immich.permission
import android.content.Context
import app.alextran.immich.core.ImmichPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
override fun hasManageMediaPermission(): Boolean =
manageMediaPermissionDelegate.hasManageMediaPermission()
override fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
}
override fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
manageMediaPermissionDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
manageMediaPermissionDelegate.onDetachedFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
manageMediaPermissionDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
manageMediaPermissionDelegate.onDetachedFromActivity()
}
}
@@ -1,133 +0,0 @@
package app.alextran.immich.sync
import android.app.Activity
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
class MediaTrashDelegate(
context: Context,
private val trashRequestCode: Int = 1002,
) : PluginRegistry.ActivityResultListener {
private val ctx = context.applicationContext
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
private fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(ctx)
} else {
false
}
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
return
}
val id = mediaId.toLongOrNull()
if (id == null) {
callback(Result.failure(FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)))
return
}
if (!isInTrash(id)) {
callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
return
}
restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreUri(
contentUri: Uri,
callback: (Result<Boolean>) -> Unit,
) {
val activity = activityBinding?.activity
if (activity == null) {
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
return
}
try {
val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, listOf(contentUri), false)
pendingResult = callback
activity.startIntentSenderForResult(
pendingIntent.intentSender,
trashRequestCode,
null,
0,
0,
0,
)
} catch (e: Exception) {
pendingResult = null
callback(
Result.failure(
FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
)
)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun isInTrash(id: Long): Boolean {
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val args = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
}
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
?.use { it.moveToFirst() } == true
}
private fun contentUriForType(type: Int): Uri =
when (type) {
// Same order as AssetType from Dart.
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
}
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
fun onDetachedFromActivity() {
failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == trashRequestCode) {
val callback = pendingResult
pendingResult = null
callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
return true
}
return false
}
private fun failPending(code: String, message: String) {
val callback = pendingResult ?: return
pendingResult = null
callback(Result.failure(FlutterError(code, message, null)))
}
}
@@ -553,7 +553,6 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit) fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing() fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>> fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object { companion object {
@@ -748,27 +747,6 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val mediaIdArg = args[0] as String
val typeArg = args[1] as Long
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
@@ -17,8 +17,6 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -41,11 +39,10 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase" private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware { open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
private var hashTask: Job? = null private var hashTask: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object { companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16 private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
@@ -451,26 +448,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null hashTask = null
} }
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
mediaTrashDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
mediaTrashDelegate.onDetachedFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
mediaTrashDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
mediaTrashDelegate.onDetachedFromActivity()
}
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs // This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER") @Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> { fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
+6 -19
View File
@@ -19,8 +19,6 @@
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; }; B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; }; B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; }; B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; }; B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; }; D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -107,8 +105,6 @@
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; }; B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; }; B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; }; B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; }; B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -287,7 +283,6 @@
B25D37792E72CA15008B6CA7 /* Connectivity */, B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */, B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
B2EE00052E72CA15008B6CA7 /* Permission */,
FA9973382CF6DF4B000EF859 /* Runner.entitlements */, FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */, FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -322,15 +317,6 @@
path = Connectivity; path = Connectivity;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B2EE00052E72CA15008B6CA7 /* Permission */ = {
isa = PBXGroup;
children = (
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
);
path = Permission;
sourceTree = "<group>";
};
FAC6F8B62D287F120078CB2F /* ShareExtension */ = { FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -633,8 +619,6 @@
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */, FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */, FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */, FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */, B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
@@ -734,7 +718,6 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share.profile;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -767,6 +750,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -817,7 +801,6 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share.debug;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
@@ -877,7 +860,6 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -912,6 +894,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -941,6 +924,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -1096,6 +1080,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1139,6 +1124,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1179,6 +1165,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
-1
View File
@@ -26,7 +26,6 @@ import native_video_player
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) { public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!) NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl()) LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl()) RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
-14
View File
@@ -288,7 +288,6 @@ protocol NetworkApi {
func hasCertificate() throws -> Bool func hasCertificate() throws -> Bool
func getClientPointer() throws -> Int64 func getClientPointer() throws -> Int64
func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws
func getAppGroupId() throws -> String
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -389,18 +388,5 @@ class NetworkApiSetup {
} else { } else {
setRequestHeadersChannel.setMessageHandler(nil) setRequestHeadersChannel.setMessageHandler(nil)
} }
let getAppGroupIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getAppGroupId\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAppGroupIdChannel.setMessageHandler { _, reply in
do {
let result = try api.getAppGroupId()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getAppGroupIdChannel.setMessageHandler(nil)
}
} }
} }
@@ -61,10 +61,6 @@ class NetworkApiImpl: NetworkApi {
return Int64(Int(bitPattern: pointer)) return Int64(Int(bitPattern: pointer))
} }
func getAppGroupId() throws -> String {
return Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
}
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws { func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
URLSessionManager.setServerUrls(serverUrls) URLSessionManager.setServerUrls(serverUrls)
@@ -4,7 +4,7 @@ import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers" let HEADERS_KEY = "immich.request_headers"
let SERVER_URLS_KEY = "immich.server_urls" let SERVER_URLS_KEY = "immich.server_urls"
let APP_GROUP = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String let APP_GROUP = "group.app.immich.share"
let COOKIE_EXPIRY_DAYS: TimeInterval = 400 let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable { enum AuthCookie: CaseIterable {
-106
View File
@@ -1,106 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(Swift.type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol PermissionApi {
func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PermissionApiSetup {
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
do {
let result = try api.hasManageMediaPermission()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
hasManageMediaPermissionChannel.setMessageHandler(nil)
}
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
api.requestManageMediaPermission { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestManageMediaPermissionChannel.setMessageHandler(nil)
}
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
manageMediaPermissionChannel.setMessageHandler { _, reply in
api.manageMediaPermission { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
manageMediaPermissionChannel.setMessageHandler(nil)
}
}
}
@@ -1,15 +0,0 @@
import Foundation
class PermissionApiImpl: PermissionApi {
func hasManageMediaPermission() throws -> Bool {
return false
}
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>$(CUSTOM_GROUP_ID)</string> <string>group.app.immich.share</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -12,7 +12,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>$(CUSTOM_GROUP_ID)</string> <string>group.app.immich.share</string>
</array> </array>
</dict> </dict>
</plist> </plist>
-19
View File
@@ -537,7 +537,6 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]] func getTrashedAssets() throws -> [String: [PlatformAsset]]
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
} }
@@ -722,24 +721,6 @@ class NativeSyncApiSetup {
} else { } else {
getTrashedAssetsChannel.setMessageHandler(nil) getTrashedAssetsChannel.setMessageHandler(nil)
} }
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
restoreFromTrashByIdChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let mediaIdArg = args[0] as! String
let typeArg = args[1] as! Int64
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
restoreFromTrashByIdChannel.setMessageHandler(nil)
}
let getCloudIdForAssetIdsChannel = taskQueue == nil let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+1 -5
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)
@@ -382,10 +382,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
func getTrashedAssets() throws -> [String: [PlatformAsset]] { func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil) throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
} }
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> { private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album // Ensure to actually getting all assets for the Recents album
@@ -4,7 +4,7 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>$(CUSTOM_GROUP_ID)</string> <string>group.app.immich.share</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
let IMMICH_SHARE_GROUP = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable { enum WidgetError: Error, Codable {
case noLogin case noLogin
-2
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
@@ -4,7 +4,7 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>$(CUSTOM_GROUP_ID)</string> <string>group.app.immich.share</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+6 -16
View File
@@ -21,7 +21,6 @@ platform :ios do
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})" CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich" BASE_BUNDLE_ID = "app.alextran.immich"
DEV_BUNDLE_ID = "tech.futo.immich.testflight" DEV_BUNDLE_ID = "tech.futo.immich.testflight"
DEV_GROUP_ID = "group.app.immich.share.testflight"
# Helper method to get App Store Connect API key # Helper method to get App Store Connect API key
def get_api_key def get_api_key
@@ -34,13 +33,6 @@ platform :ios do
) )
end end
# Helper method to assemble xcargs with optional CUSTOM_GROUP_ID override
def build_xcargs(group_id: nil)
args = "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual"
args += " CUSTOM_GROUP_ID='#{group_id}'" if group_id
args
end
# Helper method to get version from pubspec.yaml # Helper method to get version from pubspec.yaml
def get_version_from_pubspec def get_version_from_pubspec
require 'yaml' require 'yaml'
@@ -97,8 +89,7 @@ end
version_number: nil, version_number: nil,
profile_name_main:, profile_name_main:,
profile_name_share:, profile_name_share:,
profile_name_widget:, profile_name_widget:
group_id: nil
) )
app_identifier = base_bundle_id app_identifier = base_bundle_id
@@ -106,7 +97,7 @@ end
if version_number if version_number
increment_version_number(version_number: version_number) increment_version_number(version_number: version_number)
end end
# Increment build number # Increment build number
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number( build_number: latest_testflight_build_number(
@@ -115,14 +106,14 @@ end
) + 1, ) + 1,
xcodeproj: "./Runner.xcodeproj" xcodeproj: "./Runner.xcodeproj"
) )
# Build the app # Build the app
build_app( build_app(
scheme: "Runner", scheme: "Runner",
workspace: "Runner.xcworkspace", workspace: "Runner.xcworkspace",
configuration: configuration, configuration: configuration,
export_method: "app-store", export_method: "app-store",
xcargs: build_xcargs(group_id: group_id), xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: { export_options: {
provisioningProfiles: { provisioningProfiles: {
"#{app_identifier}" => profile_name_main, "#{app_identifier}" => profile_name_main,
@@ -174,8 +165,7 @@ end
distribute_external: false, distribute_external: false,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name, profile_name_widget: widget_profile_name
group_id: DEV_GROUP_ID
) )
end end
@@ -284,7 +274,7 @@ end
configuration: "Release", configuration: "Release",
export_method: "app-store", export_method: "app-store",
skip_package_ipa: true, skip_package_ipa: true,
xcargs: build_xcargs(group_id: DEV_GROUP_ID), xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: { export_options: {
provisioningProfiles: { provisioningProfiles: {
DEV_BUNDLE_ID => main_profile_name, DEV_BUNDLE_ID => main_profile_name,
+1
View File
@@ -30,6 +30,7 @@ const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64; const int kTimelineAssetLoadOppositeSize = 64;
// Widget keys // Widget keys
const String appShareGroupId = "group.app.immich.share";
const String kWidgetAuthToken = "widget_auth_token"; const String kWidgetAuthToken = "widget_auth_token";
const String kWidgetServerEndpoint = "widget_server_url"; const String kWidgetServerEndpoint = "widget_server_url";
const String kWidgetCustomHeaders = "widget_custom_headers"; const String kWidgetCustomHeaders = "widget_custom_headers";
-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,10 +9,10 @@ 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_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.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/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -23,29 +23,29 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository; final LocalFilesManagerRepository _localFilesManager;
final IPermissionRepository _permissionRepository; final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");
LocalSyncService({ LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required AssetMediaRepository assetMediaRepository, required LocalFilesManagerRepository localFilesManager,
required IPermissionRepository permissionRepository, required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository, _localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_assetMediaRepository = assetMediaRepository, _localFilesManager = localFilesManager,
_permissionRepository = permissionRepository, _storageRepository = storageRepository,
_nativeSyncApi = nativeSyncApi; _nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async { Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
try { try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _permissionRepository.hasManageMediaPermission(); final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) { if (hasPermission) {
await _syncTrashedAssets(); await _syncTrashedAssets();
} else { } else {
@@ -373,7 +373,7 @@ class LocalSyncService {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore(); final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) { if (assetsToRestore.isNotEmpty) {
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore); final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds); await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else { } else {
_log.info("syncTrashedAssets, No remote assets found for restoration"); _log.info("syncTrashedAssets, No remote assets found for restoration");
@@ -381,15 +381,15 @@ class LocalSyncService {
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash(); final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) { if (localAssetsToTrash.isNotEmpty) {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList(); final mediaUrls = await Future.wait(
_log.info("Moving to trash ${localIds.join(", ")} assets"); localAssetsToTrash.values
final movedIds = await _assetMediaRepository.deleteAll(localIds); .expand((e) => e)
if (movedIds.isNotEmpty) { .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
final movedAssetsByAlbum = localAssetsToTrash.map( );
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()), _log.info("Moving to trash ${mediaUrls.join(", ")} assets");
)..removeWhere((_, assets) => assets.isEmpty); final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum); await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
} }
} else { } else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash"); _log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
@@ -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);
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.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';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_migration.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/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -34,8 +34,8 @@ class SyncStreamService {
final SyncStreamRepository _syncStreamRepository; final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository; final LocalFilesManagerRepository _localFilesManager;
final IPermissionRepository _permissionRepository; final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository; final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api; final ApiService _api;
final bool Function()? _cancelChecker; final bool Function()? _cancelChecker;
@@ -45,8 +45,8 @@ class SyncStreamService {
required SyncStreamRepository syncStreamRepository, required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required AssetMediaRepository assetMediaRepository, required LocalFilesManagerRepository localFilesManager,
required IPermissionRepository permissionRepository, required StorageRepository storageRepository,
required SyncMigrationRepository syncMigrationRepository, required SyncMigrationRepository syncMigrationRepository,
required ApiService api, required ApiService api,
bool Function()? cancelChecker, bool Function()? cancelChecker,
@@ -54,8 +54,8 @@ class SyncStreamService {
_syncStreamRepository = syncStreamRepository, _syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository, _localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_assetMediaRepository = assetMediaRepository, _localFilesManager = localFilesManager,
_permissionRepository = permissionRepository, _storageRepository = storageRepository,
_syncMigrationRepository = syncMigrationRepository, _syncMigrationRepository = syncMigrationRepository,
_api = api, _api = api,
_cancelChecker = cancelChecker; _cancelChecker = cancelChecker;
@@ -500,22 +500,22 @@ class SyncStreamService {
} }
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async { Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList(); final mediaUrls = await Future.wait(
_logger.info("Moving to trash ${localIds.join(", ")} assets"); localAssetsToTrash.values
final movedIds = await _assetMediaRepository.deleteAll(localIds); .expand((e) => e)
if (movedIds.isNotEmpty) { .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
final movedAssetsByAlbum = localAssetsToTrash.map( );
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()), _logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
)..removeWhere((_, assets) => assets.isEmpty); final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum); await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
} }
} }
Future<void> _applyRemoteRestoreToLocal() async { Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore(); final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) { if (assetsToRestore.isNotEmpty) {
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore); final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds); await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else { } else {
_logger.info("No remote assets found for restoration"); _logger.info("No remote assets found for restoration");
@@ -523,7 +523,7 @@ class SyncStreamService {
} }
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async { Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
if (!(await _permissionRepository.hasManageMediaPermission())) { if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing"); _logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return; return;
} }
@@ -533,7 +533,7 @@ class SyncStreamService {
} }
Future<void> _syncAssetDeletion(List<String> remoteIds) async { Future<void> _syncAssetDeletion(List<String> remoteIds) async {
if (!(await _permissionRepository.hasManageMediaPermission())) { if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing"); _logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return; return;
} }
@@ -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(),
),
],
),
), ),
); );
} }
-19
View File
@@ -654,25 +654,6 @@ class NativeSyncApi {
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>(); return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
} }
Future<bool> restoreFromTrashById(String mediaId, int type) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async { Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
final pigeonVar_channelName = final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
-19
View File
@@ -309,23 +309,4 @@ class NetworkApi {
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
} }
Future<String> getAppGroupId() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.getAppGroupId$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as String;
}
} }
-119
View File
@@ -1,119 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class PermissionApi {
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<bool> hasManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> requestManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> manageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
}
@@ -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);

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