mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
Merge branch 'main' into fix/29468-share-filename
This commit is contained in:
commit
0ae86ccbc1
420 changed files with 5720 additions and 7090 deletions
27
.github/workflows/build-mobile.yml
vendored
27
.github/workflows/build-mobile.yml
vendored
|
|
@ -12,6 +12,10 @@ on:
|
|||
default: 'development'
|
||||
type: string
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_CLIENT_ID:
|
||||
required: true
|
||||
PUSH_O_MATIC_APP_KEY:
|
||||
required: true
|
||||
KEY_JKS:
|
||||
required: true
|
||||
ALIAS:
|
||||
|
|
@ -59,7 +63,7 @@ jobs:
|
|||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
|
|
@ -106,7 +110,7 @@ jobs:
|
|||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
|
@ -221,6 +225,13 @@ jobs:
|
|||
github_token: ${{ steps.token.outputs.token }}
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
working-directory: ./mobile/ios
|
||||
|
||||
- name: Install dependencies
|
||||
run: mise //mobile:install:ci
|
||||
|
||||
|
|
@ -231,18 +242,6 @@ jobs:
|
|||
working-directory: ./mobile
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
working-directory: ./mobile/ios
|
||||
|
||||
- name: Install CocoaPods dependencies
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
pod install
|
||||
|
||||
- name: Create API Key
|
||||
env:
|
||||
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
|
|
|
|||
2
.github/workflows/check-openapi.yml
vendored
2
.github/workflows/check-openapi.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@0ab8ad204b00d25acc5ae87106281433e288d0c1 # v0.1.10
|
||||
uses: oasdiff/oasdiff-action/breaking@b7c3adeb54330db1903d27c61db520e5661ad55b # v0.1.11
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
|
|
|||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:74b93dfbf13f8ee73eb35d8d5195ff667f93f9bc55df5ae453dd43a461c821fa
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
|
|
|||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
|
|
@ -71,7 +71,7 @@ jobs:
|
|||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
|
@ -84,6 +84,6 @@ jobs:
|
|||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
|
|
|||
146
.github/workflows/docker.yml
vendored
146
.github/workflows/docker.yml
vendored
|
|
@ -14,6 +14,10 @@ concurrency:
|
|||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
GHCR_NAMESPACE: ghcr.io/${{ github.repository_owner }}
|
||||
DOCKERHUB_NAMESPACE: docker.io/altran1502
|
||||
|
||||
jobs:
|
||||
pre-job:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -31,7 +35,7 @@ jobs:
|
|||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
|
|
@ -44,8 +48,6 @@ jobs:
|
|||
- 'machine-learning/**'
|
||||
force-filters: |
|
||||
- '.github/workflows/docker.yml'
|
||||
- '.github/workflows/multi-runner-build.yml'
|
||||
- '.github/actions/image-build'
|
||||
force-events: 'workflow_dispatch,release'
|
||||
|
||||
retag_ml:
|
||||
|
|
@ -58,7 +60,30 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||
include: &ml-variants
|
||||
- image: immich-machine-learning
|
||||
device: cpu
|
||||
- image: immich-machine-learning
|
||||
device: cuda
|
||||
suffixes: '-cuda'
|
||||
platforms: linux/amd64
|
||||
- image: immich-machine-learning
|
||||
device: openvino
|
||||
suffixes: '-openvino'
|
||||
platforms: linux/amd64
|
||||
- image: immich-machine-learning
|
||||
device: armnn
|
||||
suffixes: '-armnn'
|
||||
platforms: linux/arm64
|
||||
- image: immich-machine-learning
|
||||
device: rknn
|
||||
suffixes: '-rknn'
|
||||
platforms: linux/arm64
|
||||
- image: immich-machine-learning
|
||||
device: rocm
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
|
@ -70,10 +95,10 @@ jobs:
|
|||
- name: Re-tag image
|
||||
env:
|
||||
REGISTRY_NAME: 'ghcr.io'
|
||||
REPOSITORY: ${{ github.repository_owner }}/immich-machine-learning
|
||||
TAG_OLD: main${{ matrix.suffix }}
|
||||
TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
|
||||
TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }}
|
||||
REPOSITORY: ${{ github.repository_owner }}/${{ matrix.image }}
|
||||
TAG_OLD: main${{ matrix.suffixes }}
|
||||
TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffixes }}
|
||||
TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffixes }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
|
||||
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
|
||||
|
|
@ -86,9 +111,6 @@ jobs:
|
|||
packages: write
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
suffix: ['']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
|
@ -101,9 +123,9 @@ jobs:
|
|||
env:
|
||||
REGISTRY_NAME: 'ghcr.io'
|
||||
REPOSITORY: ${{ github.repository_owner }}/immich-server
|
||||
TAG_OLD: main${{ matrix.suffix }}
|
||||
TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
|
||||
TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }}
|
||||
TAG_OLD: main
|
||||
TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}
|
||||
TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
|
||||
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
|
||||
|
|
@ -115,40 +137,27 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- device: cpu
|
||||
- device: cuda
|
||||
suffixes: '-cuda'
|
||||
platforms: linux/amd64
|
||||
- device: openvino
|
||||
suffixes: '-openvino'
|
||||
platforms: linux/amd64
|
||||
- device: armnn
|
||||
suffixes: '-armnn'
|
||||
platforms: linux/arm64
|
||||
- device: rknn
|
||||
suffixes: '-rknn'
|
||||
platforms: linux/arm64
|
||||
- device: rocm
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@50dc3a14f0606ecd8fc28d78d3a3c655115ab695 # multi-runner-build-workflow-v3.1.0
|
||||
include: *ml-variants
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@4d420cf8e5ea499f99b2c2849ff9a219d5f7b178 # multi-runner-build-workflow-v4.0.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
packages: write
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
with:
|
||||
image: immich-machine-learning
|
||||
image: ${{ matrix.image }}
|
||||
context: machine-learning
|
||||
dockerfile: machine-learning/Dockerfile
|
||||
platforms: ${{ matrix.platforms }}
|
||||
runner-mapping: ${{ matrix.runner-mapping }}
|
||||
suffixes: ${{ matrix.suffixes }}
|
||||
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
|
||||
tags: &tag-rules |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,format=long,prefix=commit-
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
type=match,pattern=v(\d+),group=1,prefix=v,suffix=-rc,enable=${{ github.event.release.prerelease == true }}
|
||||
type=raw,value=release,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
build-args: |
|
||||
DEVICE=${{ matrix.device }}
|
||||
|
||||
|
|
@ -156,25 +165,72 @@ jobs:
|
|||
name: Build and Push Server
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@50dc3a14f0606ecd8fc28d78d3a3c655115ab695 # multi-runner-build-workflow-v3.1.0
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@4d420cf8e5ea499f99b2c2849ff9a219d5f7b178 # multi-runner-build-workflow-v4.0.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
packages: write
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
with:
|
||||
image: immich-server
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
|
||||
tags: *tag-rules
|
||||
build-args: |
|
||||
DEVICE=cpu
|
||||
|
||||
mirror:
|
||||
name: Mirror to Docker Hub
|
||||
needs: [machine-learning, server]
|
||||
if: ${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
permissions:
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Slightly hacky; we reuse the ml matrix but tack on immich-server too
|
||||
image: [immich-server]
|
||||
include: *ml-variants
|
||||
env:
|
||||
IMAGE: ${{ matrix.image }}
|
||||
SUFFIX: ${{ matrix.suffixes }}
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
images: ${{ env.GHCR_NAMESPACE }}/${{ env.IMAGE }}
|
||||
tags: *tag-rules
|
||||
|
||||
- name: Mirror tags to Docker Hub
|
||||
env:
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
for tag in ${TAGS}; do
|
||||
version="${tag##*:}"
|
||||
docker buildx imagetools create \
|
||||
-t "${DOCKERHUB_NAMESPACE}/${IMAGE}:${version}${SUFFIX}" \
|
||||
"${GHCR_NAMESPACE}/${IMAGE}:${version}${SUFFIX}"
|
||||
done
|
||||
|
||||
success-check-server:
|
||||
name: Docker Build & Push Server Success
|
||||
needs: [server, retag_server]
|
||||
needs: [server, retag_server, mirror]
|
||||
permissions: {}
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
|
@ -185,7 +241,7 @@ jobs:
|
|||
|
||||
success-check-ml:
|
||||
name: Docker Build & Push ML Success
|
||||
needs: [machine-learning, retag_ml]
|
||||
needs: [machine-learning, retag_ml, mirror]
|
||||
permissions: {}
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
|
|
|||
2
.github/workflows/docs-build.yml
vendored
2
.github/workflows/docs-build.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
|
|
|
|||
43
.github/workflows/prepare-release.yml
vendored
43
.github/workflows/prepare-release.yml
vendored
|
|
@ -17,10 +17,6 @@ on:
|
|||
- prepatch
|
||||
- prerelease
|
||||
- release
|
||||
mobileBump:
|
||||
description: 'Bump mobile build number'
|
||||
required: false
|
||||
type: boolean
|
||||
skipTranslations:
|
||||
description: 'Skip translations'
|
||||
required: false
|
||||
|
|
@ -79,8 +75,7 @@ jobs:
|
|||
- name: Bump version
|
||||
env:
|
||||
RELEASE_TYPE: ${{ inputs.releaseType }}
|
||||
MOBILE_BUMP: ${{ inputs.mobileBump }}
|
||||
run: mise //:release --type "${RELEASE_TYPE}" --mobile "${MOBILE_BUMP}"
|
||||
run: mise //:release --type "${RELEASE_TYPE}"
|
||||
|
||||
- id: output
|
||||
run: |
|
||||
|
|
@ -107,6 +102,8 @@ jobs:
|
|||
contents: read
|
||||
pull-requests: write
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_CLIENT_ID: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
|
@ -152,19 +149,23 @@ jobs:
|
|||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||
tag_name: ${{ needs.bump_version.outputs.version }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
generate_release_notes: true
|
||||
body_path: misc/release/notes.tmpl
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
docker/prometheus.yml
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ needs.bump_version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.bump_version.outputs.rc }}
|
||||
run: |
|
||||
gh release create "${TAG}" \
|
||||
--repo "${REPO}" \
|
||||
--draft \
|
||||
--verify-tag \
|
||||
--prerelease="${PRERELEASE}" \
|
||||
--notes-file misc/release/notes.tmpl \
|
||||
--generate-notes \
|
||||
docker/docker-compose.yml \
|
||||
docker/docker-compose.rootless.yml \
|
||||
docker/example.env \
|
||||
docker/hwaccel.ml.yml \
|
||||
docker/hwaccel.transcoding.yml \
|
||||
docker/prometheus.yml \
|
||||
*.apk
|
||||
|
|
|
|||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
|
|
|
|||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
|
|
@ -419,7 +419,7 @@ jobs:
|
|||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
@ -498,7 +498,7 @@ jobs:
|
|||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
|
|||
2
.github/workflows/weblate-lock.yml
vendored
2
.github/workflows/weblate-lock.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ services:
|
|||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.6-ubuntu@sha256:124ddf024f95e61b562a02112b47bc64de75973370cf440b1356d31cd8178844
|
||||
image: grafana/grafana:12.4.7-ubuntu@sha256:d327f509400334fbea74a282d287f20c3187222741ec99e76f253cac16c90129
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
|
|
|||
|
|
@ -28,4 +28,4 @@ run = "prettier --write ."
|
|||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||
|
||||
[tools]
|
||||
wrangler = "4.114.0"
|
||||
wrangler = "4.119.0"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useWindowSize } from '@docusaurus/theme-common';
|
|||
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const ARCHIVED_VERSIONS_URL = 'https://version.immich.cloud/v1/docs/versions';
|
||||
|
||||
export default function VersionSwitcher(): JSX.Element {
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [activeLabel, setLabel] = useState('Versions');
|
||||
|
|
@ -11,12 +13,7 @@ export default function VersionSwitcher(): JSX.Element {
|
|||
useEffect(() => {
|
||||
async function getVersions() {
|
||||
try {
|
||||
let baseUrl = 'https://docs.immich.app';
|
||||
if (window.location.origin === 'http://localhost:3005') {
|
||||
baseUrl = window.location.origin;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/archived-versions.json`);
|
||||
const response = await fetch(ARCHIVED_VERSIONS_URL);
|
||||
|
||||
const archiveVersions = await response.json();
|
||||
|
||||
|
|
|
|||
1
docs/static/_redirects
vendored
1
docs/static/_redirects
vendored
|
|
@ -1,3 +1,4 @@
|
|||
/archived-versions.json https://version.immich.cloud/v1/docs/versions 307
|
||||
/ /overview/quick-start 307
|
||||
/mobile-app-beta-program /features/mobile-app 307
|
||||
/contribution-guidelines /overview/support-the-project#contributing 307
|
||||
|
|
|
|||
265
docs/static/archived-versions.json
vendored
265
docs/static/archived-versions.json
vendored
|
|
@ -1,265 +0,0 @@
|
|||
[
|
||||
{
|
||||
"label": "v3.1.0",
|
||||
"url": "https://docs.v3.1.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v3.0.3",
|
||||
"url": "https://docs.v3.0.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
"url": "https://docs.v2.7.5.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.6.3",
|
||||
"url": "https://docs.v2.6.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
"url": "https://docs.v2.5.6.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.4.1",
|
||||
"url": "https://docs.v2.4.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.3.1",
|
||||
"url": "https://docs.v2.3.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.3",
|
||||
"url": "https://docs.v2.2.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.1.0",
|
||||
"url": "https://docs.v2.1.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.0.1",
|
||||
"url": "https://docs.v2.0.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.144.1",
|
||||
"url": "https://docs.v1.144.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.143.1",
|
||||
"url": "https://docs.v1.143.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.142.1",
|
||||
"url": "https://v1.142.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.141.1",
|
||||
"url": "https://v1.141.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.140.1",
|
||||
"url": "https://v1.140.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.139.4",
|
||||
"url": "https://v1.139.4.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.138.1",
|
||||
"url": "https://v1.138.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.137.3",
|
||||
"url": "https://v1.137.3.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.136.0",
|
||||
"url": "https://v1.136.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.135.3",
|
||||
"url": "https://v1.135.3.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.134.0",
|
||||
"url": "https://v1.134.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.133.1",
|
||||
"url": "https://v1.133.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.132.3",
|
||||
"url": "https://v1.132.3.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.131.3",
|
||||
"url": "https://v1.131.3.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.130.3",
|
||||
"url": "https://v1.130.3.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.129.0",
|
||||
"url": "https://v1.129.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.128.0",
|
||||
"url": "https://v1.128.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.127.0",
|
||||
"url": "https://v1.127.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.126.1",
|
||||
"url": "https://v1.126.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.125.7",
|
||||
"url": "https://v1.125.7.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.124.2",
|
||||
"url": "https://v1.124.2.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.123.0",
|
||||
"url": "https://v1.123.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.122.3",
|
||||
"url": "https://v1.122.3.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.121.0",
|
||||
"url": "https://v1.121.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.120.2",
|
||||
"url": "https://v1.120.2.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.119.1",
|
||||
"url": "https://v1.119.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.118.2",
|
||||
"url": "https://v1.118.2.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.117.0",
|
||||
"url": "https://v1.117.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.116.2",
|
||||
"url": "https://v1.116.2.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.115.0",
|
||||
"url": "https://v1.115.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.114.0",
|
||||
"url": "https://v1.114.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.113.1",
|
||||
"url": "https://v1.113.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.112.1",
|
||||
"url": "https://v1.112.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.111.0",
|
||||
"url": "https://v1.111.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.110.0",
|
||||
"url": "https://v1.110.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.109.2",
|
||||
"url": "https://v1.109.2.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.108.0",
|
||||
"url": "https://v1.108.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.107.2",
|
||||
"url": "https://v1.107.2.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.106.4",
|
||||
"url": "https://v1.106.4.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.105.1",
|
||||
"url": "https://v1.105.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.104.0",
|
||||
"url": "https://v1.104.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.103.1",
|
||||
"url": "https://v1.103.1.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.102.3",
|
||||
"url": "https://v1.102.3.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.101.0",
|
||||
"url": "https://v1.101.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
},
|
||||
{
|
||||
"label": "v1.100.0",
|
||||
"url": "https://v1.100.0.archive.immich.app",
|
||||
"rootPath": "/docs"
|
||||
}
|
||||
]
|
||||
|
|
@ -2,13 +2,8 @@ import js from '@eslint/js';
|
|||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
|
||||
import globals from 'globals';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import typescriptEslint from 'typescript-eslint';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default typescriptEslint.config([
|
||||
eslintPluginUnicorn.configs.recommended,
|
||||
eslintPluginPrettierRecommended,
|
||||
|
|
@ -29,7 +24,7 @@ export default typescriptEslint.config([
|
|||
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,651 +0,0 @@
|
|||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/duplicates', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
|
||||
[user1, user2] = await Promise.all([
|
||||
utils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
utils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
|
||||
// Note: We don't reset users since they're set up once in beforeAll
|
||||
// Stack must be reset before asset due to foreign key constraint
|
||||
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
|
||||
});
|
||||
|
||||
describe('GET /duplicates', () => {
|
||||
it('should return empty array when no duplicates', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/duplicates')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
|
||||
// Create assets with different file sizes for duplicate detection
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Manually set duplicateId on both assets to create a duplicate group
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000001';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/duplicates')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
{
|
||||
duplicateId,
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({ id: asset1.id }),
|
||||
expect.objectContaining({ id: asset2.id }),
|
||||
]),
|
||||
suggestedKeepAssetIds: expect.any(Array),
|
||||
},
|
||||
]);
|
||||
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /duplicates/resolve', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.send({
|
||||
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return failure for non-existent duplicate group', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId: uuidDto.dummy,
|
||||
status: 'FAILED',
|
||||
reason: expect.stringContaining('not found or access denied'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve duplicate group with keepers', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000002';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify side effects: duplicateId cleared on kept asset
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
|
||||
// Verify side effects: trashed asset is trashed and duplicateId cleared
|
||||
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(trashedAsset.isTrashed).toBe(true);
|
||||
expect(trashedAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000003';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('disjoint');
|
||||
});
|
||||
|
||||
it('should require keepAssetIds when partially trashing', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000004';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('must cover all assets');
|
||||
});
|
||||
|
||||
it('should reject partial resolution (not all assets covered)', async () => {
|
||||
const [asset1, asset2, asset3] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000010';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('must cover all assets');
|
||||
});
|
||||
|
||||
it('should reject asset not in duplicate group', async () => {
|
||||
const [asset1, asset2, outsideAsset] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000011';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('not a member of duplicate group');
|
||||
});
|
||||
|
||||
it('should allow trash-all without keepers', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000012';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify both assets are trashed
|
||||
const [asset1Info, asset2Info] = await Promise.all([
|
||||
utils.getAssetInfo(user1.accessToken, asset1.id),
|
||||
utils.getAssetInfo(user1.accessToken, asset2.id),
|
||||
]);
|
||||
|
||||
expect(asset1Info.isTrashed).toBe(true);
|
||||
expect(asset1Info.duplicateId).toBeNull();
|
||||
expect(asset2Info.isTrashed).toBe(true);
|
||||
expect(asset2Info.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject cross-user duplicate group access', async () => {
|
||||
const asset1 = await utils.createAsset(user1.accessToken);
|
||||
const asset2 = await utils.createAsset(user2.accessToken);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000013';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// User1 tries to resolve a group containing user2's asset
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('not a member of duplicate group');
|
||||
});
|
||||
|
||||
it('should synchronize favorites when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Mark one asset as favorite
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], isFavorite: true });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000020';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify favorite was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.isFavorite).toBe(true);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize visibility when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Archive one asset
|
||||
await utils.archiveAssets(user1.accessToken, [asset2.id]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000021';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify visibility was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.visibility).toBe('archive');
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize rating when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set rating on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], rating: 5 });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000022';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify rating was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.rating).toBe(5);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize description when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set description on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000023';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify description was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize location when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set location on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000024';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify location was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
|
||||
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize albums when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Create albums and add assets to different albums
|
||||
const album1 = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'Album 1',
|
||||
assetIds: [asset1.id],
|
||||
});
|
||||
const album2 = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'Album 2',
|
||||
assetIds: [asset2.id],
|
||||
});
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000025';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify keeper is now in both albums
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
|
||||
// Check albums directly
|
||||
const { status: album1Status, body: album1Body } = await request(app)
|
||||
.get(`/albums/${album1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
const { status: album2Status, body: album2Body } = await request(app)
|
||||
.get(`/albums/${album2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(album1Status).toBe(200);
|
||||
expect(album2Status).toBe(200);
|
||||
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
|
||||
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
|
||||
});
|
||||
|
||||
it('should synchronize tags when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Wait for metadata extraction to complete before adding tags
|
||||
// Otherwise, metadata jobs will race and overwrite our tags
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
// Create tags and tag assets differently
|
||||
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
|
||||
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
|
||||
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000026';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify keeper has both tags
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
expect(keptAsset.tags).toBeDefined();
|
||||
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
|
||||
expect(tagIds).toContain(tags[0].id);
|
||||
expect(tagIds).toContain(tags[1].id);
|
||||
});
|
||||
|
||||
it('should handle batch resolve with mixed success and failure', async () => {
|
||||
// Create first group that will succeed
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
|
||||
|
||||
// Create second group with non-existent duplicate ID (will fail)
|
||||
const fakeId = '00000000-0000-4000-8000-000000000099';
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [
|
||||
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
|
||||
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.status).toBe('COMPLETED');
|
||||
expect(body.results).toHaveLength(2);
|
||||
|
||||
// First group should succeed
|
||||
expect(body.results[0].duplicateId).toBe(duplicateId1);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Second group should fail
|
||||
expect(body.results[1].duplicateId).toBe(fakeId);
|
||||
expect(body.results[1].status).toBe('FAILED');
|
||||
expect(body.results[1].reason).toContain('not found or access denied');
|
||||
|
||||
// Verify first group was actually resolved despite second failure
|
||||
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(asset1Info.duplicateId).toBeNull();
|
||||
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(asset2Info.isTrashed).toBe(true);
|
||||
});
|
||||
|
||||
it('should trash assets when trash is enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000028';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// Ensure trash is enabled (default)
|
||||
const config = await utils.getSystemConfig(admin.accessToken);
|
||||
expect(config.trash.enabled).toBe(true);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify asset is trashed (not deleted)
|
||||
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(trashedAsset.isTrashed).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete assets when trash is disabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000029';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// Disable trash
|
||||
await request(app)
|
||||
.put('/system-config')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
trash: { enabled: false, days: 30 },
|
||||
});
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Asset should be marked as deleted (force delete)
|
||||
const { status: getStatus } = await request(app)
|
||||
.get(`/assets/${asset2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
// Asset should still be accessible (soft deleted) but marked as deleted
|
||||
expect(getStatus).toBe(200);
|
||||
|
||||
// Re-enable trash for other tests
|
||||
await utils.resetAdminConfig(admin.accessToken);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
export const uuidDto = {
|
||||
invalid: 'invalid-uuid',
|
||||
// valid uuid v4
|
||||
notFound: '00000000-0000-4000-a000-000000000000',
|
||||
dummy: '00000000-0000-4000-a000-000000000001',
|
||||
dummy2: '00000000-0000-4000-a000-000000000002',
|
||||
};
|
||||
|
||||
const adminLoginDto = {
|
||||
|
|
@ -57,22 +55,6 @@ export const createUserDto = {
|
|||
};
|
||||
|
||||
export const userDto = {
|
||||
admin: {
|
||||
name: signupDto.admin.name,
|
||||
email: signupDto.admin.email,
|
||||
password: signupDto.admin.password,
|
||||
storageLabel: 'admin',
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
tags: [],
|
||||
assets: [],
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
},
|
||||
user1: {
|
||||
name: createUserDto.user1.name,
|
||||
email: createUserDto.user1.email,
|
||||
|
|
|
|||
|
|
@ -40,41 +40,6 @@ export const errorDto = {
|
|||
},
|
||||
};
|
||||
|
||||
export const signupResponseDto = {
|
||||
admin: {
|
||||
avatarColor: expect.any(String),
|
||||
id: expect.any(String),
|
||||
name: 'Immich Admin',
|
||||
email: 'admin@immich.cloud',
|
||||
storageLabel: 'admin',
|
||||
profileImagePath: '',
|
||||
// why? lol
|
||||
shouldChangePassword: true,
|
||||
isAdmin: true,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
deletedAt: null,
|
||||
oauthId: '',
|
||||
quotaUsageInBytes: 0,
|
||||
quotaSizeInBytes: null,
|
||||
status: 'active',
|
||||
license: null,
|
||||
profileChangedAt: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
export const loginResponseDto = {
|
||||
admin: {
|
||||
accessToken: expect.any(String),
|
||||
name: 'Immich Admin',
|
||||
isAdmin: true,
|
||||
isOnboarded: false,
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: true,
|
||||
userEmail: 'admin@immich.cloud',
|
||||
userId: expect.any(String),
|
||||
},
|
||||
};
|
||||
export const deviceDto = {
|
||||
current: {
|
||||
id: expect.any(String),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { LoginResponseDto, createPartner } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, asBearerAuth, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
|
@ -29,13 +28,6 @@ describe('/partners', () => {
|
|||
});
|
||||
|
||||
describe('GET /partners', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/partners');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get all partners shared by user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/partners')
|
||||
|
|
@ -58,13 +50,6 @@ describe('/partners', () => {
|
|||
});
|
||||
|
||||
describe('POST /partners/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/partners/${user3.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should share with new partner', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/partners/${user3.userId}`)
|
||||
|
|
@ -85,13 +70,6 @@ describe('/partners', () => {
|
|||
});
|
||||
|
||||
describe('PUT /partners/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/partners/${user2.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should update partner', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/partners/${user2.userId}`)
|
||||
|
|
@ -104,13 +82,6 @@ describe('/partners', () => {
|
|||
});
|
||||
|
||||
describe('DELETE /partners/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(`/partners/${user3.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should delete partner', async () => {
|
||||
const { status } = await request(app)
|
||||
.delete(`/partners/${user3.userId}`)
|
||||
|
|
|
|||
|
|
@ -22,12 +22,6 @@ describe('/server', () => {
|
|||
});
|
||||
|
||||
describe('GET /server/about', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/server/about');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return about information', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/server/about')
|
||||
|
|
@ -56,12 +50,6 @@ describe('/server', () => {
|
|||
});
|
||||
|
||||
describe('GET /server/storage', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/server/storage');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return the disk information', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/server/storage')
|
||||
|
|
@ -149,12 +137,6 @@ describe('/server', () => {
|
|||
});
|
||||
|
||||
describe('GET /server/statistics', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/server/statistics');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/server/statistics')
|
||||
|
|
@ -213,12 +195,6 @@ describe('/server', () => {
|
|||
});
|
||||
|
||||
describe('GET /server/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/server/license');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/server/license')
|
||||
|
|
@ -241,12 +217,6 @@ describe('/server', () => {
|
|||
});
|
||||
|
||||
describe('DELETE /server/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete('/server/license');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete('/server/license')
|
||||
|
|
@ -266,12 +236,6 @@ describe('/server', () => {
|
|||
});
|
||||
|
||||
describe('PUT /server/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put('/server/license');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put('/server/license')
|
||||
|
|
|
|||
|
|
@ -15,12 +15,6 @@ describe('/sessions', () => {
|
|||
});
|
||||
|
||||
describe('GET /sessions', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/sessions');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get a list of authorized devices', async () => {
|
||||
const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
|
|
@ -29,12 +23,6 @@ describe('/sessions', () => {
|
|||
});
|
||||
|
||||
describe('DELETE /sessions', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(`/sessions`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should logout all devices (except the current one)', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await login({ loginCredentialDto: loginDto.admin });
|
||||
|
|
|
|||
|
|
@ -137,13 +137,6 @@ describe('/shared-links', () => {
|
|||
});
|
||||
|
||||
describe('GET /shared-links', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/shared-links');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get all shared links created by user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-links')
|
||||
|
|
@ -200,12 +193,6 @@ describe('/shared-links', () => {
|
|||
});
|
||||
|
||||
describe('GET /shared-links/me', () => {
|
||||
it('should not require admin authentication', async () => {
|
||||
const { status } = await request(app).get('/shared-links/me').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('should get data for correct shared link', async () => {
|
||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
|
||||
|
||||
|
|
@ -293,13 +280,6 @@ describe('/shared-links', () => {
|
|||
});
|
||||
|
||||
describe('GET /shared-links/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/shared-links/${linkWithAlbum.id}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get shared link by id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/shared-links/${linkWithAlbum.id}`)
|
||||
|
|
@ -326,26 +306,6 @@ describe('/shared-links', () => {
|
|||
});
|
||||
|
||||
describe('POST /shared-links', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-links')
|
||||
.send({ type: SharedLinkType.Album, albumId: uuidDto.notFound });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a type and the correspondent asset/album id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-links')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require an asset/album id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-links')
|
||||
|
|
@ -353,17 +313,17 @@ describe('/shared-links', () => {
|
|||
.send({ type: SharedLinkType.Album });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }]));
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-links')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
|
||||
.send({ type: SharedLinkType.Individual, assetIds: [uuidDto.notFound] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Not found or no asset.share access' }));
|
||||
});
|
||||
|
||||
it('should create a shared link', async () => {
|
||||
|
|
@ -383,15 +343,6 @@ describe('/shared-links', () => {
|
|||
});
|
||||
|
||||
describe('PATCH /shared-links/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/shared-links/${linkWithAlbum.id}`)
|
||||
.send({ description: 'foo' });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should fail if invalid link', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/shared-links/${uuidDto.notFound}`)
|
||||
|
|
@ -474,13 +425,6 @@ describe('/shared-links', () => {
|
|||
});
|
||||
|
||||
describe('DELETE /shared-links/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(`/shared-links/${linkWithAlbum.id}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should fail if invalid link', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/shared-links/${uuidDto.notFound}`)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, asBearerAuth, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
|
|
@ -25,42 +25,6 @@ describe('/stacks', () => {
|
|||
});
|
||||
|
||||
describe('POST /stacks', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/stacks')
|
||||
.send({ assetIds: [asset.id] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require at least two assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/stacks')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ assetIds: [asset.id] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/stacks')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['assetIds', 0], message: 'Invalid UUID' },
|
||||
{ path: ['assetIds', 1], message: 'Invalid UUID' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require access', async () => {
|
||||
const user2Asset = await utils.createAsset(user2.accessToken);
|
||||
const { status, body } = await request(app)
|
||||
|
|
|
|||
|
|
@ -16,12 +16,6 @@ describe('/server-info', () => {
|
|||
});
|
||||
|
||||
describe('POST /system-metadata/admin-onboarding', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/system-metadata/admin-onboarding').send({ isOnboarded: true });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/system-metadata/admin-onboarding')
|
||||
|
|
@ -47,12 +41,6 @@ describe('/server-info', () => {
|
|||
});
|
||||
|
||||
describe('GET /system-metadata/reverse-geocoding-state', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/system-metadata/reverse-geocoding-state');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/system-metadata/reverse-geocoding-state')
|
||||
|
|
|
|||
|
|
@ -41,12 +41,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('POST /tags', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/tags').send({ name: 'TagA' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization (api key)', async () => {
|
||||
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
|
||||
const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
|
||||
|
|
@ -117,12 +111,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('GET /tags', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/tags');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization (api key)', async () => {
|
||||
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
|
||||
const { status, body } = await request(app).get('/tags').set('x-api-key', secret);
|
||||
|
|
@ -169,12 +157,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('PUT /tags', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization (api key)', async () => {
|
||||
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
|
||||
const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' });
|
||||
|
|
@ -213,12 +195,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('PUT /tags/assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization (api key)', async () => {
|
||||
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
|
||||
const { status, body } = await request(app)
|
||||
|
|
@ -279,12 +255,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('GET /tags/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const tag = await create(user.accessToken, { name: 'TagA' });
|
||||
const { status, body } = await request(app)
|
||||
|
|
@ -304,14 +274,6 @@ describe('/tags', () => {
|
|||
expect(body).toEqual(errorDto.missingPermission('tag.read'));
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/tags/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should get tag details', async () => {
|
||||
const tag = await create(user.accessToken, { name: 'TagA' });
|
||||
const { status, body } = await request(app)
|
||||
|
|
@ -349,13 +311,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('PUT /tags/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const tag = await create(user.accessToken, { name: 'TagA' });
|
||||
const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const tag = await create(admin.accessToken, { name: 'tagA' });
|
||||
const { status, body } = await request(app)
|
||||
|
|
@ -399,12 +354,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('DELETE /tags/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const tag = await create(user.accessToken, { name: 'TagA' });
|
||||
const { status, body } = await request(app)
|
||||
|
|
@ -422,14 +371,6 @@ describe('/tags', () => {
|
|||
expect(body).toEqual(errorDto.missingPermission('tag.delete'));
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/tags/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should delete a tag', async () => {
|
||||
const tag = await create(user.accessToken, { name: 'TagA' });
|
||||
const { status } = await request(app)
|
||||
|
|
@ -463,15 +404,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('PUT /tags/:id/assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const tagA = await create(user.accessToken, { name: 'TagA' });
|
||||
const { status, body } = await request(app)
|
||||
.put(`/tags/${tagA.id}/assets`)
|
||||
.send({ ids: [userAsset.id] });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const tag = await create(user.accessToken, { name: 'TagA' });
|
||||
const { status, body } = await request(app)
|
||||
|
|
@ -531,16 +463,6 @@ describe('/tags', () => {
|
|||
});
|
||||
|
||||
describe('DELETE /tags/:id/assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const tagA = await create(admin.accessToken, { name: 'TagA' });
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/tags/${tagA}/assets`)
|
||||
.send({ ids: [userAsset.id] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const tagA = await create(user.accessToken, { name: 'TagA' });
|
||||
await tagAssets(
|
||||
|
|
|
|||
|
|
@ -46,12 +46,6 @@ describe('/admin/users', () => {
|
|||
});
|
||||
|
||||
describe('GET /admin/users', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/admin/users`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/admin/users`)
|
||||
|
|
@ -94,12 +88,6 @@ describe('/admin/users', () => {
|
|||
});
|
||||
|
||||
describe('POST /admin/users', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/admin/users`).send(createUserDto.user1);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/admin/users`)
|
||||
|
|
@ -109,23 +97,6 @@ describe('/admin/users', () => {
|
|||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
for (const [key, message] of [
|
||||
['password', 'Invalid input: expected string, received null'],
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||
['notify', 'Invalid input: expected boolean, received null'],
|
||||
] as const) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/admin/users`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ...createUserDto.user1, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
});
|
||||
}
|
||||
|
||||
it('should accept `isAdmin`', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/admin/users`)
|
||||
|
|
@ -146,12 +117,6 @@ describe('/admin/users', () => {
|
|||
});
|
||||
|
||||
describe('PUT /admin/users/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/admin/users/${uuidDto.notFound}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/admin/users/${uuidDto.notFound}`)
|
||||
|
|
@ -160,22 +125,6 @@ describe('/admin/users', () => {
|
|||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
for (const [key, message] of [
|
||||
['password', 'Invalid input: expected string, received null'],
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||
] as const) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/admin/users/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
});
|
||||
}
|
||||
|
||||
it('should allow a non-admin to become an admin', async () => {
|
||||
const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2'));
|
||||
const { status, body } = await request(app)
|
||||
|
|
@ -245,12 +194,6 @@ describe('/admin/users', () => {
|
|||
});
|
||||
|
||||
describe('PUT /admin/users/:id/preferences', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/admin/users/${userToDelete.userId}/preferences`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should update memories enabled', async () => {
|
||||
const before = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
expect(before).toMatchObject({ memories: { enabled: true } });
|
||||
|
|
@ -282,12 +225,6 @@ describe('/admin/users', () => {
|
|||
});
|
||||
|
||||
describe('DELETE /admin/users/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/admin/users/${userToDelete.userId}`)
|
||||
|
|
@ -360,12 +297,6 @@ describe('/admin/users', () => {
|
|||
});
|
||||
|
||||
describe('GET /admin/users/:id/calendar-heatmap', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require admin permissions', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`)
|
||||
|
|
@ -385,12 +316,6 @@ describe('/admin/users', () => {
|
|||
});
|
||||
|
||||
describe('POST /admin/users/:id/restore', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/admin/users/${userToDelete.userId}/restore`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/admin/users/${userToDelete.userId}/restore`)
|
||||
|
|
|
|||
|
|
@ -1,34 +1,18 @@
|
|||
import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@immich/sdk';
|
||||
import { LoginResponseDto, SharedLinkType, getMyUser, login } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, asBearerAuth, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const userLicense = {
|
||||
licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4',
|
||||
activationKey:
|
||||
'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw',
|
||||
};
|
||||
|
||||
describe('/users', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let deletedUser: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({ onboarding: false });
|
||||
|
||||
[deletedUser, nonAdmin] = await Promise.all([
|
||||
utils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
utils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
]);
|
||||
|
||||
await deleteUserAdmin(
|
||||
{ id: deletedUser.userId, userAdminDeleteDto: {} },
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2);
|
||||
});
|
||||
|
||||
describe('GET /users/me', () => {
|
||||
|
|
@ -42,55 +26,9 @@ describe('/users', () => {
|
|||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
it('should get my user', async () => {
|
||||
const { status, body } = await request(app).get(`/users/me`).set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
id: admin.userId,
|
||||
email: 'admin@immich.cloud',
|
||||
quotaUsageInBytes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get my user with license info', async () => {
|
||||
const { status: licenseStatus } = await request(app)
|
||||
.put(`/users/me/license`)
|
||||
.send(userLicense)
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
expect(licenseStatus).toBe(200);
|
||||
const { status, body } = await request(app)
|
||||
.get(`/users/me`)
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
id: nonAdmin.userId,
|
||||
email: nonAdmin.userEmail,
|
||||
quotaUsageInBytes: 0,
|
||||
license: userLicense,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me', () => {
|
||||
it('should update first and last name', async () => {
|
||||
const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me`)
|
||||
.send({ name: 'Name' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...before,
|
||||
updatedAt: expect.any(String),
|
||||
profileChangedAt: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
name: 'Name',
|
||||
});
|
||||
});
|
||||
|
||||
/** @deprecated */
|
||||
it('should allow a user to change their password (deprecated)', async () => {
|
||||
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
|
||||
|
|
@ -112,222 +50,5 @@ describe('/users', () => {
|
|||
|
||||
expect(token.accessToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not allow user to change to a taken email', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me`)
|
||||
.send({ email: 'admin@immich.cloud' })
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toMatchObject(errorDto.badRequest('Email is not available'));
|
||||
});
|
||||
|
||||
it('should update my email', async () => {
|
||||
const before = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me`)
|
||||
.send({ email: 'non-admin@immich.cloud' })
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
...before,
|
||||
email: 'non-admin@immich.cloud',
|
||||
updatedAt: expect.anything(),
|
||||
createdAt: expect.anything(),
|
||||
profileChangedAt: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should update avatar color', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me`)
|
||||
.send({ avatarColor: 'blue' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ avatarColor: 'blue' });
|
||||
|
||||
const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ avatarColor: 'blue' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me/preferences', () => {
|
||||
it('should update memories enabled', async () => {
|
||||
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(before).toMatchObject({ memories: { enabled: true } });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me/preferences`)
|
||||
.send({ memories: { enabled: false } })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ memories: { enabled: false } });
|
||||
|
||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ memories: { enabled: false } });
|
||||
});
|
||||
|
||||
it('should require an integer for download archive size', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me/preferences`)
|
||||
.send({ download: { archiveSize: 1_234_567.89 } })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update download archive size', async () => {
|
||||
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(before).toMatchObject({ download: { archiveSize: 4 * 2 ** 30 } });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me/preferences`)
|
||||
.send({ download: { archiveSize: 1_234_567 } })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ download: { archiveSize: 1_234_567 } });
|
||||
|
||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } });
|
||||
});
|
||||
|
||||
it('should require a boolean for download include embedded videos', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me/preferences`)
|
||||
.send({ download: { includeEmbeddedVideos: 1_234_567.89 } })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update download include embedded videos', async () => {
|
||||
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me/preferences`)
|
||||
.send({ download: { includeEmbeddedVideos: true } })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } });
|
||||
|
||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
|
||||
});
|
||||
|
||||
it('should update minimum face count to display people', async () => {
|
||||
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(before).toMatchObject({ people: { minimumFaces: 3 } });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put('/users/me/preferences')
|
||||
.send({ people: { minimumFaces: 2 } })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ people: { minimumFaces: 2 } });
|
||||
|
||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ people: { minimumFaces: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should get the user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/users/${admin.userId}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
id: admin.userId,
|
||||
email: 'admin@immich.cloud',
|
||||
});
|
||||
|
||||
expect(body).not.toMatchObject({
|
||||
shouldChangePassword: expect.anything(),
|
||||
storageLabel: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /server/license', () => {
|
||||
it('should return the user license', async () => {
|
||||
await request(app)
|
||||
.put('/users/me/license')
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
|
||||
.send(userLicense);
|
||||
const { status, body } = await request(app)
|
||||
.get('/users/me/license')
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...userLicense,
|
||||
activatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me/license', () => {
|
||||
it('should set the user license', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me/license`)
|
||||
.send(userLicense)
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ ...userLicense, activatedAt: expect.any(String) });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ ...userLicense, activatedAt: expect.any(String) });
|
||||
const { body: licenseBody } = await request(app)
|
||||
.get('/users/me/license')
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
expect(licenseBody).toEqual({ ...userLicense, activatedAt: expect.any(String) });
|
||||
});
|
||||
|
||||
it('should reject license not starting with IMCL-', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put('/users/me/license')
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
|
||||
.send({ licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', activationKey: 'activationKey' });
|
||||
expect(status).toBe(400);
|
||||
expect(body.message).toBe('Invalid license key');
|
||||
});
|
||||
|
||||
it('should reject license with invalid activation key', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put('/users/me/license')
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
|
||||
.send({ licenseKey: userLicense.licenseKey, activationKey: `invalid${userLicense.activationKey}` });
|
||||
expect(status).toBe(400);
|
||||
expect(body.message).toBe('Invalid license key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/me/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(app).put(`/users/me/license`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
|
||||
it('should delete the user license', async () => {
|
||||
const { status } = await request(app)
|
||||
.delete(`/users/me/license`)
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,13 +24,6 @@ export const ASPECT_RATIO_WEIGHTS = {
|
|||
'3:1': 0.01, // 1% 3:1 panorama
|
||||
} as const;
|
||||
|
||||
export type AspectRatio = {
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Mock configuration for asset generation - will be transformed to API response formats
|
||||
export type MockTimelineAsset = {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BrowserContext, expect, Page } from '@playwright/test';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import { TimelineAssetConfig } from 'src/ui/generators/timeline';
|
||||
|
||||
|
|
@ -11,18 +11,6 @@ export const padYearMonth = (yearMonth: string) => {
|
|||
return `${year}-${month.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export async function throttlePage(context: BrowserContext, page: Page) {
|
||||
const session = await context.newCDPSession(page);
|
||||
await session.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
downloadThroughput: (1.5 * 1024 * 1024) / 8,
|
||||
uploadThroughput: (750 * 1024) / 8,
|
||||
latency: 40,
|
||||
connectionType: 'cellular3g',
|
||||
});
|
||||
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
||||
}
|
||||
|
||||
export const poll = async <T>(
|
||||
page: Page,
|
||||
query: () => Promise<T>,
|
||||
|
|
|
|||
10
i18n/en.json
10
i18n/en.json
|
|
@ -1788,6 +1788,7 @@
|
|||
"restore_all": "Restore all",
|
||||
"restore_user": "Restore user",
|
||||
"restored_asset": "Restored asset",
|
||||
"result": "Result",
|
||||
"resume": "Resume",
|
||||
"resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
|
||||
"review_duplicates": "Review duplicates",
|
||||
|
|
@ -2139,6 +2140,8 @@
|
|||
"trigger": "Trigger",
|
||||
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
|
||||
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
|
||||
"trigger_asset_tagged": "Asset Tagged",
|
||||
"trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
|
||||
"trigger_asset_uploaded": "Asset Upload",
|
||||
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
|
||||
"trigger_person_recognized": "Person Recognized",
|
||||
|
|
@ -2276,6 +2279,13 @@
|
|||
"workflow_info": "Workflow info",
|
||||
"workflow_json": "Workflow JSON",
|
||||
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
|
||||
"workflow_logging_completed": "Completed",
|
||||
"workflow_logging_disable": "Disable logging",
|
||||
"workflow_logging_disabled_description": "Logging is currently disabled for this workflow.",
|
||||
"workflow_logging_enable": "Enable logging",
|
||||
"workflow_logging_error_step": "Error on step #{step}",
|
||||
"workflow_logging_halted": "Halted",
|
||||
"workflow_logging_halted_step": "Halted on step #{step}",
|
||||
"workflow_name": "Workflow name",
|
||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||
"workflow_summary": "Workflow summary",
|
||||
|
|
|
|||
74
mise.lock
74
mise.lock
|
|
@ -104,17 +104,17 @@ version = "7.1.3-6"
|
|||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-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-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-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"]]
|
||||
version = "7.1.3-6"
|
||||
|
|
@ -133,17 +133,17 @@ version = "7.1.3-6"
|
|||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
|
||||
[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-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-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.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:webassembly/binaryen"]]
|
||||
version = "version_124"
|
||||
|
|
@ -291,43 +291,43 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12.
|
|||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/484602547"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "11.17.0"
|
||||
version = "11.20.0"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
checksum = "sha256:730d17de742a3efbb020ba91d7acfc0456c6ba6ad1cd8eb49f4c229fe9f504d3"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422123"
|
||||
checksum = "sha256:f00fc2041bb41742b7943bf2bb24183ad20320e8384824a8031eb94edf2f57a5"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142378"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:cc072c0e7bdd290f52fb9afdcea778ede69f96f0f4dae63623163fc11d4beec3"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-linux-arm64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422121"
|
||||
checksum = "sha256:2a21235d3f0fbfbcb9e530b9a98f2c64dcee95d1d5d1c2d04428255c7b85f32c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142372"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
checksum = "sha256:bdb1db01bf0f757495405a59a09c5c287f315889dc98d3b14bc374b9fe43a0bf"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-linux-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422122"
|
||||
checksum = "sha256:b4ad6ad2b21db2f8cd50af416c3aa148ba704c31c84893f465a770a01c2c4572"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142376"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:db2e4eeecab336bd41bbbc7a39626b166f1a15bb189efcf6a1dafdf159f23fe7"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-linux-x64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422116"
|
||||
checksum = "sha256:db046881c027f1a2d69e9a530a21a62c2bced4ba8ff437e7300426ae56951fe4"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142377"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
checksum = "sha256:1e9a35d76f9d382af365d95c64f3abf7815e24350653f5bb1a37e4546265bc2b"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422117"
|
||||
checksum = "sha256:4bc97fea72e5c92eec1fefc8d410c35d01e0d0d52f3160c59f38c32db58b5cd2"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142373"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
checksum = "sha256:3025c174a6dcffec14a071a5c854cb0c350a09a8c7c2857a75f75cc7b29b939c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-win32-x64.zip"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422118"
|
||||
checksum = "sha256:ea2528bdc3d96a1ff3c35587dc48ca692b39d77f08f26df4adeaaa9eb427024e"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-win32-x64.zip"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142371"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ config_roots = [
|
|||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
pnpm = "11.17.0"
|
||||
pnpm = "11.20.0"
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.5"
|
||||
"npm:@openapitools/openapi-generator-cli" = "2.40.1"
|
||||
|
|
|
|||
|
|
@ -26,52 +26,59 @@ linter:
|
|||
# producing the lint.
|
||||
|
||||
rules:
|
||||
# Formatting
|
||||
avoid_print: true
|
||||
require_trailing_commas: true
|
||||
unrelated_type_equality_checks: true
|
||||
prefer_const_constructors: true
|
||||
always_use_package_imports: true
|
||||
always_put_control_body_on_new_line: true
|
||||
# Correctness
|
||||
always_declare_return_types: true
|
||||
avoid_type_to_string: true
|
||||
avoid_unused_constructor_parameters: true
|
||||
avoid_void_async: true
|
||||
cancel_subscriptions: true
|
||||
cast_nullable_to_non_nullable: true
|
||||
close_sinks: true
|
||||
collection_methods_unrelated_type: true
|
||||
deprecated_consistency: true
|
||||
discarded_futures: true
|
||||
no_adjacent_strings_in_list: true
|
||||
no_self_assignments: true
|
||||
noop_primitive_operations: true
|
||||
only_throw_errors: true
|
||||
parameter_assignments: true
|
||||
throw_in_finally: true
|
||||
tighten_type_of_initializing_formals: true
|
||||
unawaited_futures: true
|
||||
unnecessary_ignore: true
|
||||
unnecessary_null_checks: true
|
||||
unnecessary_parenthesis: true
|
||||
prefer_final_locals: true
|
||||
unnecessary_statements: true
|
||||
unrelated_type_equality_checks: true
|
||||
|
||||
# Performance
|
||||
avoid_slow_async_io: true
|
||||
prefer_const_constructors: true
|
||||
prefer_const_declarations: true
|
||||
prefer_const_literals_to_create_immutables: true
|
||||
use_super_parameters: true
|
||||
directives_ordering: true
|
||||
no_leading_underscores_for_local_identifiers: true
|
||||
always_declare_return_types: true
|
||||
avoid_void_async: true
|
||||
noop_primitive_operations: true
|
||||
use_named_constants: true
|
||||
combinators_ordering: true
|
||||
avoid_multiple_declarations_per_line: true
|
||||
unnecessary_breaks: true
|
||||
|
||||
# Correctness
|
||||
no_adjacent_strings_in_list: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
unawaited_futures: true
|
||||
discarded_futures: true
|
||||
no_self_assignments: true
|
||||
throw_in_finally: true
|
||||
collection_methods_unrelated_type: true
|
||||
cast_nullable_to_non_nullable: true
|
||||
|
||||
# Known issues
|
||||
avoid_slow_async_io: true
|
||||
avoid_type_to_string: true
|
||||
|
||||
# Flutter specific
|
||||
use_build_context_synchronously: true
|
||||
avoid_unnecessary_containers: true
|
||||
sized_box_for_whitespace: true
|
||||
use_build_context_synchronously: true
|
||||
use_colored_box: true
|
||||
use_decorated_box: true
|
||||
avoid_unnecessary_containers: true
|
||||
use_full_hex_values_for_flutter_colors: true
|
||||
|
||||
# Style
|
||||
always_put_control_body_on_new_line: true
|
||||
always_use_package_imports: true
|
||||
avoid_multiple_declarations_per_line: true
|
||||
avoid_print: true
|
||||
combinators_ordering: true
|
||||
directives_ordering: true
|
||||
no_leading_underscores_for_local_identifiers: true
|
||||
prefer_final_locals: true
|
||||
require_trailing_commas: true
|
||||
unnecessary_breaks: true
|
||||
unnecessary_parenthesis: true
|
||||
use_named_constants: true
|
||||
use_super_parameters: true
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
analyzer:
|
||||
|
|
@ -97,6 +104,18 @@ dart_code_metrics:
|
|||
exclude-paths:
|
||||
- 'lib/utils/debug_print.dart'
|
||||
severity: perf
|
||||
- name: tr
|
||||
description: 'Use context.t.<key> or StaticTranslations.instance.<key> instead of tr().'
|
||||
- type: String
|
||||
entries:
|
||||
- name: tr
|
||||
description: 'Use context.t.<key> or StaticTranslations.instance.<key> instead of String.tr().'
|
||||
- name: plural
|
||||
description: 'Use the generated plural accessor instead of String.plural().'
|
||||
- type: Text
|
||||
entries:
|
||||
- name: tr
|
||||
description: 'Use Text(context.t.<key>) instead of Text(<key>).tr().'
|
||||
# All rules from "recommended" preset
|
||||
# Show potential errors
|
||||
# - avoid-cascade-after-if-null
|
||||
|
|
|
|||
|
|
@ -97,6 +97,15 @@ const _kIntParamNames = [
|
|||
'quantity',
|
||||
];
|
||||
|
||||
const _kParamTypeOverrides = <String, String>{
|
||||
'advanced_settings_clear_image_cache_success.size': 'String',
|
||||
'backup_controller_page_storage_format.total': 'String',
|
||||
'backup_controller_page_storage_format.used': 'String',
|
||||
'cleanup_found_assets_with_size.size': 'String',
|
||||
};
|
||||
|
||||
final _usedParamTypeOverrides = <String>{};
|
||||
|
||||
void main() async {
|
||||
final sourceFile = File('../i18n/en.json');
|
||||
if (!await sourceFile.exists()) {
|
||||
|
|
@ -143,6 +152,11 @@ class TranslationParam {
|
|||
Future<void> _generateTranslations(Map<String, dynamic> translations, File output) async {
|
||||
final root = _buildTranslationTree('', translations);
|
||||
|
||||
final staleOverrides = _kParamTypeOverrides.keys.toSet().difference(_usedParamTypeOverrides);
|
||||
if (staleOverrides.isNotEmpty) {
|
||||
throw StateError('_kParamTypeOverrides has entries matching no plain {placeholder}: ${staleOverrides.join(', ')}');
|
||||
}
|
||||
|
||||
final buffer = StringBuffer('''
|
||||
// DO NOT EDIT. This is code generated via generate_keys.dart
|
||||
|
||||
|
|
@ -194,21 +208,22 @@ class Translations extends _BaseTranslations {
|
|||
await output.writeAsString(buffer.toString());
|
||||
}
|
||||
|
||||
TranslationNode _buildTranslationTree(String key, dynamic value) {
|
||||
TranslationNode _buildTranslationTree(String key, dynamic value, [String parentKey = '']) {
|
||||
final fullKey = parentKey.isEmpty ? key : '$parentKey.$key';
|
||||
if (value is Map<String, dynamic>) {
|
||||
final children = <String, TranslationNode>{};
|
||||
for (final entry in value.entries) {
|
||||
children[entry.key] = _buildTranslationTree(entry.key, entry.value);
|
||||
children[entry.key] = _buildTranslationTree(entry.key, entry.value, fullKey);
|
||||
}
|
||||
return TranslationNode(key: key, children: children);
|
||||
} else {
|
||||
final stringValue = value.toString();
|
||||
final params = _extractParams(stringValue);
|
||||
final params = _extractParams(stringValue, fullKey);
|
||||
return TranslationNode(key: key, value: stringValue, params: params);
|
||||
}
|
||||
}
|
||||
|
||||
List<TranslationParam> _extractParams(String value) {
|
||||
List<TranslationParam> _extractParams(String value, String fullKey) {
|
||||
final params = <String, TranslationParam>{};
|
||||
|
||||
final icuRegex = RegExp(r'\{(\w+),\s*(plural|select|number|date|time)([^}]*(?:\{[^}]*\}[^}]*)*)\}');
|
||||
|
|
@ -264,8 +279,11 @@ List<TranslationParam> _extractParams(String value) {
|
|||
continue;
|
||||
}
|
||||
|
||||
String type;
|
||||
if (_kIntParamNames.contains(name.toLowerCase())) {
|
||||
final String type;
|
||||
if (_kParamTypeOverrides['$fullKey.$name'] case final override?) {
|
||||
type = override;
|
||||
_usedParamTypeOverrides.add('$fullKey.$name');
|
||||
} else if (_kIntParamNames.contains(name.toLowerCase())) {
|
||||
type = 'int';
|
||||
} else {
|
||||
type = 'Object';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
|
||||
import '../test_utils/general_helper.dart';
|
||||
|
||||
|
|
@ -7,45 +7,32 @@ void main() async {
|
|||
await ImmichTestHelper.initialize();
|
||||
|
||||
group("Login input validation test", () {
|
||||
immichWidgetTest("Test leading/trailing whitespace",
|
||||
(tester, helper) async {
|
||||
immichWidgetTest("Test leading/trailing whitespace", (tester, helper) async {
|
||||
await helper.loginHelper.waitForLoginScreen();
|
||||
await helper.loginHelper.acknowledgeNewServerVersion();
|
||||
|
||||
await helper.loginHelper.enterCredentials(
|
||||
email: " demo@immich.app",
|
||||
);
|
||||
await helper.loginHelper.enterCredentials(email: " demo@immich.app");
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(
|
||||
find.text("login_form_err_leading_whitespace".tr()),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text(StaticTranslations.instance.login_form_err_leading_whitespace), findsOneWidget);
|
||||
|
||||
await helper.loginHelper.enterCredentials(
|
||||
email: "demo@immich.app ",
|
||||
);
|
||||
await helper.loginHelper.enterCredentials(email: "demo@immich.app ");
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(
|
||||
find.text("login_form_err_trailing_whitespace".tr()),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text(StaticTranslations.instance.login_form_err_trailing_whitespace), findsOneWidget);
|
||||
});
|
||||
|
||||
immichWidgetTest("Test invalid email", (tester, helper) async {
|
||||
await helper.loginHelper.waitForLoginScreen();
|
||||
await helper.loginHelper.acknowledgeNewServerVersion();
|
||||
|
||||
await helper.loginHelper.enterCredentials(
|
||||
email: "demo.immich.app",
|
||||
);
|
||||
await helper.loginHelper.enterCredentials(email: "demo.immich.app");
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text("login_form_err_invalid_email".tr()), findsOneWidget);
|
||||
expect(find.text(StaticTranslations.instance.login_form_err_invalid_email), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
|
||||
import 'general_helper.dart';
|
||||
|
||||
|
|
@ -23,11 +23,7 @@ class ImmichTestLoginHelper {
|
|||
return true;
|
||||
}
|
||||
|
||||
Future<void> enterCredentials({
|
||||
String server = "",
|
||||
String email = "",
|
||||
String password = "",
|
||||
}) async {
|
||||
Future<void> enterCredentials({String server = "", String email = "", String password = ""}) async {
|
||||
final loginForms = find.byType(TextFormField);
|
||||
|
||||
await tester.enterText(loginForms.at(0), email);
|
||||
|
|
@ -44,49 +40,30 @@ class ImmichTestLoginHelper {
|
|||
}
|
||||
|
||||
Future<void> enterCredentialsOf(LoginCredentials credentials) async {
|
||||
await enterCredentials(
|
||||
server: credentials.server,
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
);
|
||||
await enterCredentials(server: credentials.server, email: credentials.email, password: credentials.password);
|
||||
}
|
||||
|
||||
Future<void> pressLoginButton() async {
|
||||
await pumpUntilFound(
|
||||
tester,
|
||||
find.textContaining("login_form_button_text".tr()),
|
||||
);
|
||||
final button = find.textContaining("login_form_button_text".tr());
|
||||
await pumpUntilFound(tester, find.textContaining(StaticTranslations.instance.login));
|
||||
final button = find.textContaining(StaticTranslations.instance.login);
|
||||
await tester.tap(button);
|
||||
}
|
||||
|
||||
Future<void> assertLoginSuccess() async {
|
||||
await pumpUntilFound(tester, find.text("home_page_building_timeline".tr()));
|
||||
await pumpUntilFound(tester, find.text(StaticTranslations.instance.home_page_building_timeline));
|
||||
}
|
||||
|
||||
Future<void> assertLoginFailed() async {
|
||||
await pumpUntilFound(tester, find.text("login_form_failed_login".tr()));
|
||||
await pumpUntilFound(tester, find.text(StaticTranslations.instance.login_form_failed_login));
|
||||
}
|
||||
}
|
||||
|
||||
enum LoginCredentials {
|
||||
testInstance(
|
||||
"https://flutter-int-test.preview.immich.app",
|
||||
"demo@immich.app",
|
||||
"demo",
|
||||
),
|
||||
testInstance("https://flutter-int-test.preview.immich.app", "demo@immich.app", "demo"),
|
||||
|
||||
testInstanceButWithWrongPassword(
|
||||
"https://flutter-int-test.preview.immich.app",
|
||||
"demo@immich.app",
|
||||
"wrong",
|
||||
),
|
||||
testInstanceButWithWrongPassword("https://flutter-int-test.preview.immich.app", "demo@immich.app", "wrong"),
|
||||
|
||||
wrongInstanceUrl(
|
||||
"https://does-not-exist.preview.immich.app",
|
||||
"demo@immich.app",
|
||||
"demo",
|
||||
);
|
||||
wrongInstanceUrl("https://does-not-exist.preview.immich.app", "demo@immich.app", "demo");
|
||||
|
||||
const LoginCredentials(this.server, this.email, this.password);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,40 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class FeatureHighlight {
|
||||
enum FeatureHighlight {
|
||||
shareQuality(image: 'assets/feature_message/share_quality.webp'),
|
||||
slideshow(image: 'assets/feature_message/slideshow.webp'),
|
||||
recentlyAdded(image: 'assets/feature_message/recently_added.webp'),
|
||||
ocr(image: 'assets/feature_message/ocr.webp'),
|
||||
openInImmich(image: 'assets/feature_message/open_in_immich.webp', platform: [.android]),
|
||||
uploadToAlbum();
|
||||
|
||||
/// Asset path of the feature screenshot, or null to show a placeholder.
|
||||
final String? image;
|
||||
final String titleKey;
|
||||
final String bodyKey;
|
||||
final List<TargetPlatform> platform;
|
||||
|
||||
const FeatureHighlight({
|
||||
this.image,
|
||||
required this.titleKey,
|
||||
required this.bodyKey,
|
||||
this.platform = const [.iOS, .android],
|
||||
});
|
||||
const FeatureHighlight({this.image, this.platform = const [.iOS, .android]});
|
||||
|
||||
bool get isVisibleOnCurrentPlatform => platform.contains(defaultTargetPlatform);
|
||||
|
||||
String title(Translations t) => switch (this) {
|
||||
FeatureHighlight.shareQuality => t.share_quality_title,
|
||||
FeatureHighlight.slideshow => t.slideshow_title,
|
||||
FeatureHighlight.recentlyAdded => t.recently_added_title,
|
||||
FeatureHighlight.ocr => t.ocr_title,
|
||||
FeatureHighlight.openInImmich => t.open_in_immich_title,
|
||||
FeatureHighlight.uploadToAlbum => t.upload_to_album_title,
|
||||
};
|
||||
|
||||
String body(Translations t) => switch (this) {
|
||||
FeatureHighlight.shareQuality => t.share_quality_body,
|
||||
FeatureHighlight.slideshow => t.slideshow_body,
|
||||
FeatureHighlight.recentlyAdded => t.recently_added_body,
|
||||
FeatureHighlight.ocr => t.ocr_body,
|
||||
FeatureHighlight.openInImmich => t.open_in_immich_body,
|
||||
FeatureHighlight.uploadToAlbum => t.upload_to_album_body,
|
||||
};
|
||||
}
|
||||
|
||||
/// The release this batch of highlights was authored for. Content-defined:
|
||||
|
|
@ -24,31 +43,4 @@ const featureMessageRelease = SemVer(major: 3, minor: 0, patch: 0);
|
|||
|
||||
/// Highlights relevant to the current platform.
|
||||
List<FeatureHighlight> get visibleFeatureMessageHighlights =>
|
||||
featureMessageHighlights.where((h) => h.isVisibleOnCurrentPlatform).toList();
|
||||
|
||||
const List<FeatureHighlight> featureMessageHighlights = [
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/share_quality.webp',
|
||||
titleKey: 'share_quality_title',
|
||||
bodyKey: 'share_quality_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/slideshow.webp',
|
||||
titleKey: 'slideshow_title',
|
||||
bodyKey: 'slideshow_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/recently_added.webp',
|
||||
titleKey: 'recently_added_title',
|
||||
bodyKey: 'recently_added_body',
|
||||
),
|
||||
|
||||
FeatureHighlight(image: 'assets/feature_message/ocr.webp', titleKey: 'ocr_title', bodyKey: 'ocr_body'),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/open_in_immich.webp',
|
||||
titleKey: 'open_in_immich_title',
|
||||
bodyKey: 'open_in_immich_body',
|
||||
platform: [.android],
|
||||
),
|
||||
FeatureHighlight(titleKey: 'upload_to_album_title', bodyKey: 'upload_to_album_body'),
|
||||
];
|
||||
FeatureHighlight.values.where((h) => h.isVisibleOnCurrentPlatform).toList();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
|
|
|||
|
|
@ -3,32 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||
|
||||
part 'person.model.freezed.dart';
|
||||
|
||||
// TODO: Remove PersonDto once Isar is removed
|
||||
@freezed
|
||||
abstract class PersonDto with _$PersonDto {
|
||||
const factory PersonDto({
|
||||
required String id,
|
||||
DateTime? birthDate,
|
||||
required bool isHidden,
|
||||
required String name,
|
||||
required String thumbnailPath,
|
||||
DateTime? updatedAt,
|
||||
}) = _PersonDto;
|
||||
}
|
||||
|
||||
// Model for a person stored in the server
|
||||
@freezed
|
||||
abstract class DriftPerson with _$DriftPerson {
|
||||
const factory DriftPerson({
|
||||
required String id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required String ownerId,
|
||||
required String name,
|
||||
String? faceAssetId,
|
||||
required bool isFavorite,
|
||||
required bool isHidden,
|
||||
required String? color,
|
||||
DateTime? birthDate,
|
||||
}) = _DriftPerson;
|
||||
abstract class Person with _$Person {
|
||||
const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person;
|
||||
}
|
||||
|
|
|
|||
23
mobile/lib/domain/models/server_capability.model.dart
Normal file
23
mobile/lib/domain/models/server_capability.model.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
enum ServerCapability {
|
||||
// Feature Support
|
||||
cloudIdMetadata(SemVer(major: 2, minor: 4, patch: 0)),
|
||||
bulkCloudIdMetadata(SemVer(major: 2, minor: 5, patch: 0)),
|
||||
assetEdits(SemVer(major: 2, minor: 6, patch: 0)),
|
||||
assetFacesV2(SemVer(major: 2, minor: 6, patch: 0)),
|
||||
syncV2(SemVer(major: 3, minor: 0, patch: 0)),
|
||||
assetOcr(SemVer(major: 3, minor: 0, patch: 0)),
|
||||
|
||||
// Migrations
|
||||
assetPayloadChange20260128(SemVer(major: 2, minor: 5, patch: 0)),
|
||||
assetPayloadChange20260597(SemVer(major: 2, minor: 7, patch: 6));
|
||||
|
||||
const ServerCapability(this.minVersion);
|
||||
|
||||
final SemVer minVersion;
|
||||
}
|
||||
|
||||
extension ServerCapabilitySupport on SemVer {
|
||||
bool supports(ServerCapability capability) => this >= capability.minVersion;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class FeatureMessageService {
|
|||
|
||||
bool shouldShow() {
|
||||
final seen = _settingsRepository.appConfig.featureMessage.seenRelease;
|
||||
return featureMessageHighlights.isNotEmpty && featureMessageRelease > seen;
|
||||
return visibleFeatureMessageHighlights.isNotEmpty && featureMessageRelease > seen;
|
||||
}
|
||||
|
||||
Future<void> markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease);
|
||||
|
|
|
|||
|
|
@ -10,16 +10,16 @@ class DriftPeopleService {
|
|||
|
||||
const DriftPeopleService(this._repository, this._personApiRepository);
|
||||
|
||||
Future<DriftPerson?> get(String personId) {
|
||||
Future<Person?> get(String personId) {
|
||||
return _repository.get(personId);
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAssetPeople(String assetId) {
|
||||
Future<List<Person>> getAssetPeople(String assetId) {
|
||||
return _repository.getAssetPeople(assetId);
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAllPeople({int minFaces = 3}) {
|
||||
return _repository.getAllPeople(minFaces: minFaces);
|
||||
Stream<List<Person>> watch({int minFaces = 3}) {
|
||||
return _repository.watch(minFaces: minFaces);
|
||||
}
|
||||
|
||||
Future<int> updateName(String personId, String name) async {
|
||||
|
|
@ -27,7 +27,7 @@ class DriftPeopleService {
|
|||
return _repository.updateName(personId, name);
|
||||
}
|
||||
|
||||
Future<int> updateBrithday(String personId, DateTime birthday) async {
|
||||
Future<int> updateBirthday(String personId, DateTime birthday) async {
|
||||
await _personApiRepository.update(personId, birthday: birthday);
|
||||
return _repository.updateBirthday(personId, birthday);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/server_capability.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
|
@ -122,7 +123,7 @@ class SyncStreamService {
|
|||
}
|
||||
|
||||
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
|
||||
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
|
||||
semVer.supports(.assetPayloadChange20260128)) {
|
||||
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
|
||||
await _syncApiRepository.deleteSyncAck([
|
||||
SyncEntityType.assetV1,
|
||||
|
|
@ -139,7 +140,7 @@ class SyncStreamService {
|
|||
}
|
||||
|
||||
if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) &&
|
||||
semVer > const SemVer(major: 2, minor: 7, patch: 5)) {
|
||||
semVer.supports(.assetPayloadChange20260597)) {
|
||||
_logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2");
|
||||
await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]);
|
||||
migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/server_capability.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
|
@ -31,12 +32,12 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
|||
await _populateCloudIds(db);
|
||||
|
||||
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
|
||||
final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
|
||||
final canUpdateMetadata = serverInfo.serverVersion.supports(.cloudIdMetadata);
|
||||
if (!canUpdateMetadata) {
|
||||
logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
|
||||
return;
|
||||
}
|
||||
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
|
||||
final canBulkUpdateMetadata = serverInfo.serverVersion.supports(.bulkCloudIdMetadata);
|
||||
|
||||
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:intl/message_format.dart';
|
||||
|
||||
extension StringTranslateExtension on String {
|
||||
String t({BuildContext? context, Map<String, Object>? args}) {
|
||||
return _translateHelper(context, this, args);
|
||||
}
|
||||
}
|
||||
|
||||
extension TextTranslateExtension on Text {
|
||||
Text t({BuildContext? context, Map<String, Object>? args}) {
|
||||
return Text(
|
||||
_translateHelper(context, data ?? '', args),
|
||||
key: key,
|
||||
style: style,
|
||||
strutStyle: strutStyle,
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
locale: locale,
|
||||
softWrap: softWrap,
|
||||
overflow: overflow,
|
||||
textScaler: textScaler,
|
||||
maxLines: maxLines,
|
||||
semanticsLabel: semanticsLabel,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _translateHelper(BuildContext? context, String key, [Map<String, Object>? args]) {
|
||||
if (key.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
final translatedMessage = key.tr(context: context);
|
||||
return args != null
|
||||
? MessageFormat(translatedMessage, locale: Intl.defaultLocale ?? 'en').format(args)
|
||||
: translatedMessage;
|
||||
} catch (e) {
|
||||
dPrint(() => 'Translation failed for key "$key". Error: $e');
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,14 +8,14 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
|||
final Drift _db;
|
||||
const DriftPeopleRepository(this._db) : super(_db);
|
||||
|
||||
Future<DriftPerson?> get(String personId) async {
|
||||
Future<Person?> get(String personId) async {
|
||||
final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId));
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
return result?.toDto();
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
|
||||
Future<List<Person>> getAssetPeople(String assetId) async {
|
||||
// An asset can have multiple face records for the same person (e.g., metadata
|
||||
// imports alongside ML detections). Use a subquery instead of a join so each
|
||||
// person is returned once, regardless of how many of their faces are on the asset
|
||||
|
|
@ -33,7 +33,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
|||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAllPeople({int minFaces = 3}) async {
|
||||
Stream<List<Person>> watch({int minFaces = 3}) {
|
||||
final people = _db.personEntity;
|
||||
final faces = _db.assetFaceEntity;
|
||||
final assets = _db.remoteAssetEntity;
|
||||
|
|
@ -59,7 +59,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
|||
return query.map((row) {
|
||||
final person = row.readTable(people);
|
||||
return person.toDto();
|
||||
}).get();
|
||||
}).watch();
|
||||
}
|
||||
|
||||
Future<int> updateName(String personId, String name) {
|
||||
|
|
@ -76,18 +76,5 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
|||
}
|
||||
|
||||
extension on PersonEntityData {
|
||||
DriftPerson toDto() {
|
||||
return DriftPerson(
|
||||
id: id,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
ownerId: ownerId,
|
||||
name: name,
|
||||
faceAssetId: faceAssetId,
|
||||
isFavorite: isFavorite,
|
||||
isHidden: isHidden,
|
||||
color: color,
|
||||
birthDate: birthDate,
|
||||
);
|
||||
}
|
||||
Person toDto() => Person(id: id, updatedAt: updatedAt, name: name, birthDate: birthDate);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/server_capability.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
|
|
@ -44,25 +45,16 @@ class SyncApiRepository {
|
|||
types: [
|
||||
SyncRequestType.authUsersV1,
|
||||
SyncRequestType.usersV1,
|
||||
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
|
||||
? SyncRequestType.assetsV2
|
||||
: SyncRequestType.assetsV1,
|
||||
serverVersion.supports(.syncV2) ? SyncRequestType.assetsV2 : SyncRequestType.assetsV1,
|
||||
SyncRequestType.assetExifsV1,
|
||||
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
|
||||
if (serverVersion.supports(.assetEdits)) SyncRequestType.assetEditsV1,
|
||||
SyncRequestType.assetMetadataV1,
|
||||
SyncRequestType.partnersV1,
|
||||
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
|
||||
? SyncRequestType.partnerAssetsV2
|
||||
: SyncRequestType.partnerAssetsV1,
|
||||
serverVersion.supports(.syncV2) ? SyncRequestType.partnerAssetsV2 : SyncRequestType.partnerAssetsV1,
|
||||
SyncRequestType.partnerAssetExifsV1,
|
||||
if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0))
|
||||
SyncRequestType.albumsV1
|
||||
else
|
||||
SyncRequestType.albumsV2,
|
||||
serverVersion.supports(.syncV2) ? SyncRequestType.albumsV2 : SyncRequestType.albumsV1,
|
||||
SyncRequestType.albumUsersV1,
|
||||
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
|
||||
? SyncRequestType.albumAssetsV2
|
||||
: SyncRequestType.albumAssetsV1,
|
||||
serverVersion.supports(.syncV2) ? SyncRequestType.albumAssetsV2 : SyncRequestType.albumAssetsV1,
|
||||
SyncRequestType.albumAssetExifsV1,
|
||||
SyncRequestType.albumToAssetsV1,
|
||||
SyncRequestType.memoriesV1,
|
||||
|
|
@ -71,10 +63,8 @@ class SyncApiRepository {
|
|||
SyncRequestType.partnerStacksV1,
|
||||
SyncRequestType.userMetadataV1,
|
||||
SyncRequestType.peopleV1,
|
||||
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
|
||||
? SyncRequestType.assetFacesV2
|
||||
: SyncRequestType.assetFacesV1,
|
||||
if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
|
||||
serverVersion.supports(.assetFacesV2) ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1,
|
||||
if (serverVersion.supports(.assetOcr)) SyncRequestType.assetOcrV1,
|
||||
],
|
||||
).toJson(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import 'package:immich_mobile/constants/constants.dart';
|
|||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
|
|
@ -273,15 +272,12 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
|||
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
|
||||
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
|
||||
builder: (context, child) => ImmichTranslationProvider(
|
||||
translations: ImmichTranslations(
|
||||
submit: "submit".t(context: context),
|
||||
password: "password".t(context: context),
|
||||
),
|
||||
translations: ImmichTranslations(submit: context.t.submit, password: context.t.password),
|
||||
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
|
||||
),
|
||||
routerConfig: router.config(
|
||||
deepLinkBuilder: _deepLinkBuilder,
|
||||
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
|
||||
navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
enum LivePhotosPart { video, image }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
|
@ -194,7 +193,7 @@ class SearchFilter {
|
|||
String? language;
|
||||
String? assetId;
|
||||
List<String>? tagIds;
|
||||
Set<PersonDto> people;
|
||||
Set<Person> people;
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
SearchDateFilter date;
|
||||
|
|
@ -250,7 +249,7 @@ class SearchFilter {
|
|||
String? language,
|
||||
String? ocr,
|
||||
String? assetId,
|
||||
Set<PersonDto>? people,
|
||||
Set<Person>? people,
|
||||
List<String>? tagIds,
|
||||
SearchLocationFilter? location,
|
||||
SearchCameraFilter? camera,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_config.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_features.model.dart';
|
||||
|
|
@ -16,9 +16,9 @@ enum VersionStatus {
|
|||
|
||||
String get message => switch (this) {
|
||||
VersionStatus.upToDate => "",
|
||||
VersionStatus.clientOutOfDate => "app_update_available".tr(),
|
||||
VersionStatus.serverOutOfDate => "server_update_available".tr(),
|
||||
VersionStatus.error => "unable_to_check_version".tr(),
|
||||
VersionStatus.clientOutOfDate => StaticTranslations.instance.app_update_available,
|
||||
VersionStatus.serverOutOfDate => StaticTranslations.instance.server_update_available,
|
||||
VersionStatus.error => StaticTranslations.instance.unable_to_check_version,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,4 @@ class ServerVersion extends SemVer {
|
|||
|
||||
ServerVersion.fromDto(ServerVersionResponseDto dto)
|
||||
: super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
|
||||
|
||||
bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) {
|
||||
return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
|
|
@ -10,7 +9,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
|
|
@ -108,7 +106,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
title: Text("backup_controller_page_backup".t()),
|
||||
title: Text(context.t.backup_controller_page_backup),
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
unawaited(context.maybePop(true));
|
||||
|
|
@ -122,7 +120,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||
unawaited(context.pushRoute(const DriftBackupOptionsRoute()));
|
||||
},
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: "backup_options".t(context: context),
|
||||
tooltip: context.t.backup_options,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -318,17 +316,16 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final backupAlbums = ref.watch(backupAlbumProvider);
|
||||
|
||||
Widget buildSelectedAlbumName() {
|
||||
String text = "backup_controller_page_backup_selected".tr();
|
||||
final albums = ref
|
||||
.read(backupAlbumProvider)
|
||||
.where((album) => album.backupSelection == BackupSelection.selected)
|
||||
.toList();
|
||||
String text = context.t.backup_controller_page_backup_selected;
|
||||
final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.selected).toList();
|
||||
|
||||
if (albums.isNotEmpty) {
|
||||
for (var album in albums) {
|
||||
if (album.name == "Recent" || album.name == "Recents") {
|
||||
text += "${album.name} (${'all'.tr()}), ";
|
||||
text += "${album.name} (${context.t.all}), ";
|
||||
} else {
|
||||
text += "${album.name}, ";
|
||||
}
|
||||
|
|
@ -345,7 +342,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
|
|||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
"backup_controller_page_none_selected".tr(),
|
||||
context.t.backup_controller_page_none_selected,
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
);
|
||||
|
|
@ -353,11 +350,8 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
|
|||
}
|
||||
|
||||
Widget buildExcludedAlbumName() {
|
||||
String text = "backup_controller_page_excluded".tr();
|
||||
final albums = ref
|
||||
.read(backupAlbumProvider)
|
||||
.where((album) => album.backupSelection == BackupSelection.excluded)
|
||||
.toList();
|
||||
String text = context.t.backup_controller_page_excluded;
|
||||
final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
|
||||
|
||||
if (albums.isNotEmpty) {
|
||||
for (var album in albums) {
|
||||
|
|
@ -385,16 +379,16 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
|
|||
borderOnForeground: false,
|
||||
child: ListTile(
|
||||
minVerticalPadding: 18,
|
||||
title: Text("backup_controller_page_albums", style: context.textTheme.titleMedium).tr(),
|
||||
title: Text(context.t.backup_controller_page_albums, style: context.textTheme.titleMedium),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"backup_controller_page_to_backup",
|
||||
context.t.backup_controller_page_to_backup,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
).tr(),
|
||||
),
|
||||
buildSelectedAlbumName(),
|
||||
buildExcludedAlbumName(),
|
||||
],
|
||||
|
|
@ -413,7 +407,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
|
|||
}
|
||||
unawaited(ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id));
|
||||
},
|
||||
child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
child: Text(context.t.select, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -428,8 +422,8 @@ class _TotalCard extends ConsumerWidget {
|
|||
final totalCount = ref.watch(driftBackupProvider.select((p) => p.totalCount));
|
||||
|
||||
return BackupInfoCard(
|
||||
title: "total".tr(),
|
||||
subtitle: "backup_controller_page_total_sub".tr(),
|
||||
title: context.t.total,
|
||||
subtitle: context.t.backup_controller_page_total_sub,
|
||||
info: totalCount.toString(),
|
||||
);
|
||||
}
|
||||
|
|
@ -444,8 +438,8 @@ class _BackupCard extends ConsumerWidget {
|
|||
final syncStatus = ref.watch(syncStatusProvider);
|
||||
|
||||
return BackupInfoCard(
|
||||
title: "backup_controller_page_backup".tr(),
|
||||
subtitle: "backup_controller_page_backup_sub".tr(),
|
||||
title: context.t.backup_controller_page_backup,
|
||||
subtitle: context.t.backup_controller_page_backup_sub,
|
||||
info: backupCount.toString(),
|
||||
isLoading: syncStatus.isRemoteSyncing,
|
||||
);
|
||||
|
|
@ -472,11 +466,11 @@ class _RemainderCard extends ConsumerWidget {
|
|||
ListTile(
|
||||
minVerticalPadding: 18,
|
||||
isThreeLine: true,
|
||||
title: Text("backup_controller_page_remainder".t(context: context), style: context.textTheme.titleMedium),
|
||||
title: Text(context.t.backup_controller_page_remainder, style: context.textTheme.titleMedium),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, right: 18.0),
|
||||
child: Text(
|
||||
"backup_controller_page_remainder_sub".t(context: context),
|
||||
context.t.backup_controller_page_remainder_sub,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
),
|
||||
|
|
@ -509,11 +503,11 @@ class _RemainderCard extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
Text(
|
||||
"backup_info_card_assets",
|
||||
context.t.backup_info_card_assets,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -530,7 +524,7 @@ class _RemainderCard extends ConsumerWidget {
|
|||
),
|
||||
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
|
||||
title: Text(
|
||||
"view_details".t(context: context),
|
||||
context.t.view_details,
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
||||
),
|
||||
trailing: Icon(Icons.arrow_forward_ios, size: 16, color: context.colorScheme.onSurfaceVariant),
|
||||
|
|
@ -622,7 +616,7 @@ class _PreparingStatusState extends ConsumerState {
|
|||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"preparing".t(context: context),
|
||||
context.t.preparing,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colorScheme.onSurface.withAlpha(200),
|
||||
),
|
||||
|
|
@ -652,7 +646,7 @@ class _PreparingStatusState extends ConsumerState {
|
|||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
"ready_for_upload".t(context: context),
|
||||
context.t.ready_for_upload,
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.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/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
|
|
@ -154,13 +153,13 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||
),
|
||||
title: _isSearchMode
|
||||
? SearchField(
|
||||
hintText: 'search_albums'.t(context: context),
|
||||
hintText: context.t.search_albums,
|
||||
autofocus: true,
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
onChanged: (value) => setState(() => _searchQuery = value.trim()),
|
||||
)
|
||||
: const Text("backup_album_selection_page_select_albums").t(context: context),
|
||||
: Text(context.t.backup_album_selection_page_select_albums),
|
||||
actions: [
|
||||
if (!_isSearchMode)
|
||||
IconButton(
|
||||
|
|
@ -194,9 +193,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
|
||||
child: Text(
|
||||
"backup_album_selection_page_selection_info",
|
||||
context.t.backup_album_selection_page_selection_info,
|
||||
style: context.textTheme.titleSmall,
|
||||
).t(context: context),
|
||||
),
|
||||
),
|
||||
|
||||
// Selected Album Chips
|
||||
|
|
@ -211,15 +210,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),
|
||||
context.t.albums_on_device_count(count: albumCount),
|
||||
style: context.textTheme.titleSmall,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
"backup_album_selection_page_albums_tap",
|
||||
context.t.backup_album_selection_page_albums_tap,
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
|
||||
).t(context: context),
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
splashRadius: 16,
|
||||
|
|
@ -235,20 +234,20 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||
),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'backup_album_selection_page_selection_info',
|
||||
context.t.backup_album_selection_page_selection_info,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).t(context: context),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text(
|
||||
'backup_album_selection_page_assets_scatter',
|
||||
style: TextStyle(fontSize: 14),
|
||||
).t(context: context),
|
||||
Text(
|
||||
context.t.backup_album_selection_page_assets_scatter,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -268,16 +267,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: _searchQuery.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text('album_search_not_found'.t(context: context)),
|
||||
)
|
||||
? Padding(padding: const EdgeInsets.all(24.0), child: Text(context.t.album_search_not_found))
|
||||
: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text('no_albums_found'.t(context: context)),
|
||||
),
|
||||
: Padding(padding: const EdgeInsets.all(24.0), child: Text(context.t.no_albums_found)),
|
||||
),
|
||||
)
|
||||
else
|
||||
|
|
@ -309,7 +302,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
const CircularProgressIndicator(strokeWidth: 4),
|
||||
Text('creating_linked_albums'.tr(), style: context.textTheme.labelLarge),
|
||||
Text(context.t.creating_linked_albums, style: context.textTheme.labelLarge),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -476,10 +469,7 @@ class _SelectAllButton extends ConsumerWidget {
|
|||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.select_all),
|
||||
label: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text("select_all".t(context: context)),
|
||||
),
|
||||
label: AnimatedSwitcher(duration: const Duration(milliseconds: 200), child: Text(context.t.select_all)),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12.0)),
|
||||
),
|
||||
),
|
||||
|
|
@ -496,7 +486,7 @@ class _SelectAllButton extends ConsumerWidget {
|
|||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.deselect),
|
||||
label: Text('deselect_all'.t(context: context)),
|
||||
label: Text(context.t.deselect_all),
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12.0)),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
|
@ -7,7 +6,7 @@ import 'package:immich_mobile/domain/models/events.model.dart';
|
|||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
|
|
@ -22,7 +21,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<List<LocalAsset>> result = ref.watch(driftBackupCandidateProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('backup_controller_page_remainder'.t(context: context))),
|
||||
appBar: AppBar(title: Text(context.t.backup_controller_page_remainder)),
|
||||
body: result.when(
|
||||
data: (List<LocalAsset> candidates) {
|
||||
return ListView.separated(
|
||||
|
|
@ -66,7 +65,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget {
|
|||
);
|
||||
},
|
||||
error: (error, stackTrace) => Text(
|
||||
'error_saving_image'.tr(args: [error.toString()]),
|
||||
context.t.error_saving_image(error: error.toString()),
|
||||
style: TextStyle(color: context.colorScheme.error),
|
||||
),
|
||||
loading: () =>
|
||||
|
|
@ -102,7 +101,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget {
|
|||
);
|
||||
},
|
||||
error: (Object error, StackTrace stackTrace) {
|
||||
return Center(child: Text('error_saving_image'.tr(args: [error.toString()])));
|
||||
return Center(child: Text(context.t.error_saving_image(error: error.toString())));
|
||||
},
|
||||
loading: () {
|
||||
return const SizedBox(height: 48, width: 48, child: Center(child: CircularProgressIndicator.adaptive()));
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'dart:async';
|
|||
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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
|
|
@ -51,10 +51,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
|||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("network_requirements_updated".t(context: context)),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
SnackBar(content: Text(context.t.network_requirements_updated), duration: const Duration(seconds: 4)),
|
||||
);
|
||||
|
||||
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||
|
|
@ -72,7 +69,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
|||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: Text("backup_options".t(context: context))),
|
||||
appBar: AppBar(title: Text(context.t.backup_options)),
|
||||
body: const DriftBackupSettings(),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
|
@ -80,7 +80,7 @@ class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("upload_details".t(context: context)),
|
||||
title: Text(context.t.upload_details),
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
|
|
@ -125,7 +125,7 @@ class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
|
|||
SliverToBoxAdapter(
|
||||
child: _buildSectionHeader(
|
||||
context,
|
||||
title: "uploading".t(context: context),
|
||||
title: context.t.uploading,
|
||||
count: uploadingItems.length,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
|
|
@ -151,7 +151,7 @@ class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
|
|||
SliverToBoxAdapter(
|
||||
child: _buildSectionHeader(
|
||||
context,
|
||||
title: "errors_text".t(context: context),
|
||||
title: context.t.errors_text,
|
||||
count: failedItems.length,
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
|
|
@ -231,7 +231,7 @@ class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"downloading_from_icloud".t(context: context),
|
||||
context.t.downloading_from_icloud,
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -318,7 +318,7 @@ class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
|
|||
const SizedBox(height: 2),
|
||||
Text(
|
||||
isFailed
|
||||
? item.error ?? "unable_to_upload_file".t(context: context)
|
||||
? item.error ?? context.t.errors.unable_to_upload_file
|
||||
: "${formatHumanReadableBytes(item.fileSize, 1)} • ${item.networkSpeedAsString}",
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: isFailed
|
||||
|
|
@ -395,7 +395,7 @@ class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
|
|||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.error ?? "unable_to_upload_file".t(context: context),
|
||||
item.error ?? context.t.errors.unable_to_upload_file,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -556,7 +556,7 @@ class FileDetailDialog extends ConsumerWidget {
|
|||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"details".t(context: context),
|
||||
context.t.details,
|
||||
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
|
|
@ -595,20 +595,14 @@ class FileDetailDialog extends ConsumerWidget {
|
|||
const SizedBox(height: 24),
|
||||
if (asset != null)
|
||||
_buildInfoSection(context, [
|
||||
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
|
||||
_buildInfoRow(context, "local_id".t(context: context), asset.id),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
"file_size".t(context: context),
|
||||
formatHumanReadableBytes(uploadStatus.fileSize, 2),
|
||||
),
|
||||
if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"),
|
||||
if (asset.height != null)
|
||||
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
|
||||
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
|
||||
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
|
||||
if (asset.checksum != null)
|
||||
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
|
||||
_buildInfoRow(context, context.t.filename, path.basename(uploadStatus.filename)),
|
||||
_buildInfoRow(context, context.t.local_id, asset.id),
|
||||
_buildInfoRow(context, context.t.file_size, formatHumanReadableBytes(uploadStatus.fileSize, 2)),
|
||||
if (asset.width != null) _buildInfoRow(context, context.t.width, "${asset.width}px"),
|
||||
if (asset.height != null) _buildInfoRow(context, context.t.height, "${asset.height}px"),
|
||||
_buildInfoRow(context, context.t.created_at, asset.createdAt.toString()),
|
||||
_buildInfoRow(context, context.t.updated_at, asset.updatedAt.toString()),
|
||||
if (asset.checksum != null) _buildInfoRow(context, context.t.checksum, asset.checksum!),
|
||||
]),
|
||||
],
|
||||
),
|
||||
|
|
@ -620,7 +614,7 @@ class FileDetailDialog extends ConsumerWidget {
|
|||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
"close".t(),
|
||||
context.t.close,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/immich_logger.service.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AppLogPage extends HookWidget {
|
||||
|
|
@ -50,7 +51,7 @@ class AppLogPage extends HookWidget {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('logs'.tr(), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)),
|
||||
title: Text(context.t.logs, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)),
|
||||
scrolledUnderElevation: 1,
|
||||
elevation: 2,
|
||||
actions: [
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AppLogDetailPage extends HookWidget {
|
||||
|
|
@ -44,7 +44,7 @@ class AppLogDetailPage extends HookWidget {
|
|||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"copied_to_clipboard".tr(),
|
||||
context.t.copied_to_clipboard,
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
|
|
@ -106,7 +106,7 @@ class AppLogDetailPage extends HookWidget {
|
|||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text("log_detail_title".tr())),
|
||||
appBar: AppBar(title: Text(context.t.log_detail_title)),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||
|
||||
class DownloadPanel extends ConsumerWidget {
|
||||
|
|
@ -67,14 +67,14 @@ class DownloadTaskTile extends StatelessWidget {
|
|||
final progressPercent = (progress * 100).round();
|
||||
|
||||
String getStatusText() => switch (status) {
|
||||
TaskStatus.running => 'downloading'.tr(),
|
||||
TaskStatus.complete => 'download_complete'.tr(),
|
||||
TaskStatus.failed => 'download_failed'.tr(),
|
||||
TaskStatus.canceled => 'download_canceled'.tr(),
|
||||
TaskStatus.paused => 'download_paused'.tr(),
|
||||
TaskStatus.enqueued => 'download_enqueue'.tr(),
|
||||
TaskStatus.notFound => 'download_notfound'.tr(),
|
||||
TaskStatus.waitingToRetry => 'download_waiting_to_retry'.tr(),
|
||||
TaskStatus.running => context.t.downloading,
|
||||
TaskStatus.complete => context.t.download_complete,
|
||||
TaskStatus.failed => context.t.download_failed,
|
||||
TaskStatus.canceled => context.t.download_canceled,
|
||||
TaskStatus.paused => context.t.download_paused,
|
||||
TaskStatus.enqueued => context.t.download_enqueue,
|
||||
TaskStatus.notFound => context.t.download_notfound,
|
||||
TaskStatus.waitingToRetry => context.t.download_waiting_to_retry,
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
|
@ -64,7 +63,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
|||
headers.value = headers.value.toList();
|
||||
},
|
||||
icon: const Icon(Icons.add_outlined),
|
||||
tooltip: 'header_settings_add_header_tip'.tr(),
|
||||
tooltip: context.t.header_settings_add_header_tip,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -111,7 +110,7 @@ class HeaderKeyValueSettings extends StatelessWidget {
|
|||
|
||||
String? emptyFieldValidator(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'header_settings_field_validator_msg'.tr();
|
||||
return StaticTranslations.instance.header_settings_field_validator_msg;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -129,10 +128,12 @@ class HeaderKeyValueSettings extends StatelessWidget {
|
|||
child: TextFormField(
|
||||
controller: keyController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'header_settings_header_name_input'.tr(),
|
||||
labelText: context.t.header_settings_header_name_input,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
autocorrect: false,
|
||||
smartDashesType: .disabled,
|
||||
smartQuotesType: .disabled,
|
||||
onChanged: (headerKey) {
|
||||
header.key = headerKey;
|
||||
},
|
||||
|
|
@ -157,10 +158,12 @@ class HeaderKeyValueSettings extends StatelessWidget {
|
|||
child: TextFormField(
|
||||
controller: valueController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'header_settings_header_value_input'.tr(),
|
||||
labelText: context.t.header_settings_header_value_input,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
autocorrect: false,
|
||||
smartDashesType: .disabled,
|
||||
smartQuotesType: .disabled,
|
||||
onChanged: (headerValue) {
|
||||
header.value = headerValue;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class LargeLeadingTile extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
|
@ -18,21 +17,45 @@ import 'package:immich_mobile/widgets/settings/preference_settings/preference_se
|
|||
import 'package:immich_mobile/widgets/settings/settings_card.dart';
|
||||
|
||||
enum SettingSection {
|
||||
advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"),
|
||||
assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"),
|
||||
backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"),
|
||||
freeUpSpace('free_up_space', Icons.cleaning_services_outlined, "free_up_space_settings_subtitle"),
|
||||
languages('language', Icons.language, "setting_languages_subtitle"),
|
||||
networking('networking_settings', Icons.wifi, "networking_subtitle"),
|
||||
notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"),
|
||||
preferences('preferences_settings_title', Icons.interests_outlined, "preferences_settings_subtitle"),
|
||||
timeline('asset_list_settings_title', Icons.auto_awesome_mosaic_outlined, "asset_list_settings_subtitle"),
|
||||
beta('sync_status', Icons.sync_outlined, "sync_status_subtitle");
|
||||
advanced(Icons.build_outlined),
|
||||
assetViewer(Icons.image_outlined),
|
||||
backup(Icons.cloud_upload_outlined),
|
||||
freeUpSpace(Icons.cleaning_services_outlined),
|
||||
languages(Icons.language),
|
||||
networking(Icons.wifi),
|
||||
notifications(Icons.notifications_none_rounded),
|
||||
preferences(Icons.interests_outlined),
|
||||
timeline(Icons.auto_awesome_mosaic_outlined),
|
||||
beta(Icons.sync_outlined);
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
|
||||
String title(Translations t) => switch (this) {
|
||||
SettingSection.advanced => t.advanced,
|
||||
SettingSection.assetViewer => t.asset_viewer_settings_title,
|
||||
SettingSection.backup => t.backup,
|
||||
SettingSection.freeUpSpace => t.free_up_space,
|
||||
SettingSection.languages => t.language,
|
||||
SettingSection.networking => t.networking_settings,
|
||||
SettingSection.notifications => t.notifications,
|
||||
SettingSection.preferences => t.preferences_settings_title,
|
||||
SettingSection.timeline => t.asset_list_settings_title,
|
||||
SettingSection.beta => t.sync_status,
|
||||
};
|
||||
|
||||
String subtitle(Translations t) => switch (this) {
|
||||
SettingSection.advanced => t.advanced_settings_tile_subtitle,
|
||||
SettingSection.assetViewer => t.asset_viewer_settings_subtitle,
|
||||
SettingSection.backup => t.backup_settings_subtitle,
|
||||
SettingSection.freeUpSpace => t.free_up_space_settings_subtitle,
|
||||
SettingSection.languages => t.setting_languages_subtitle,
|
||||
SettingSection.networking => t.networking_subtitle,
|
||||
SettingSection.notifications => t.setting_notifications_subtitle,
|
||||
SettingSection.preferences => t.preferences_settings_subtitle,
|
||||
SettingSection.timeline => t.asset_list_settings_subtitle,
|
||||
SettingSection.beta => t.sync_status_subtitle,
|
||||
};
|
||||
|
||||
Widget get widget => switch (this) {
|
||||
SettingSection.advanced => const AdvancedSettings(),
|
||||
SettingSection.assetViewer => const AssetViewerSettings(),
|
||||
|
|
@ -46,7 +69,7 @@ enum SettingSection {
|
|||
SettingSection.beta => const SyncStatusAndActions(),
|
||||
};
|
||||
|
||||
const SettingSection(this.title, this.icon, this.subtitle);
|
||||
const SettingSection(this.icon);
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
|
|
@ -55,9 +78,8 @@ class SettingsPage extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.locale;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
|
||||
appBar: AppBar(centerTitle: false, title: Text(context.t.settings)),
|
||||
body: context.isMobile ? const _MobileLayout() : const _TabletLayout(),
|
||||
);
|
||||
}
|
||||
|
|
@ -73,15 +95,15 @@ class _MobileLayout extends StatelessWidget {
|
|||
? [
|
||||
SettingsCard(
|
||||
icon: Icons.sync_outlined,
|
||||
title: 'sync_status'.tr(),
|
||||
subtitle: 'sync_status_subtitle'.tr(),
|
||||
title: context.t.sync_status,
|
||||
subtitle: context.t.sync_status_subtitle,
|
||||
settingRoute: const SyncStatusRoute(),
|
||||
),
|
||||
]
|
||||
: [
|
||||
SettingsCard(
|
||||
title: setting.title.tr(),
|
||||
subtitle: setting.subtitle.tr(),
|
||||
title: setting.title(context.t),
|
||||
subtitle: setting.subtitle(context.t),
|
||||
icon: setting.icon,
|
||||
settingRoute: SettingsSubRoute(section: setting),
|
||||
),
|
||||
|
|
@ -116,7 +138,7 @@ class _TabletLayout extends HookWidget {
|
|||
...SettingSection.values.map(
|
||||
(s) => SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text(s.title).tr(),
|
||||
title: Text(s.title(context.t)),
|
||||
leading: Icon(s.icon),
|
||||
selected: s.index == selectedSection.value.index,
|
||||
selectedColor: context.primaryColor,
|
||||
|
|
@ -127,7 +149,7 @@ class _TabletLayout extends HookWidget {
|
|||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text('whats_new'.tr()),
|
||||
title: Text(context.t.whats_new),
|
||||
leading: const Icon(Icons.auto_awesome_outlined),
|
||||
onTap: () => context.pushRoute(const WhatsNewRoute()),
|
||||
),
|
||||
|
|
@ -150,9 +172,8 @@ class SettingsSubPage extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.locale;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
|
||||
appBar: AppBar(centerTitle: false, title: Text(section.title(context.t))),
|
||||
body: section.widget,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||
|
|
@ -34,24 +33,24 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
|||
|
||||
final navigationDestinations = [
|
||||
NavigationDestination(
|
||||
label: 'photos'.tr(),
|
||||
label: context.t.photos,
|
||||
icon: const Icon(Icons.photo_library_outlined),
|
||||
selectedIcon: Icon(Icons.photo_library, color: context.primaryColor),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'search'.tr(),
|
||||
label: context.t.search,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
selectedIcon: Icon(Icons.search, color: context.primaryColor),
|
||||
enabled: !isReadonlyModeEnabled,
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'albums'.tr(),
|
||||
label: context.t.albums,
|
||||
icon: const Icon(Icons.photo_album_outlined),
|
||||
selectedIcon: Icon(Icons.photo_album_rounded, color: context.primaryColor),
|
||||
enabled: !isReadonlyModeEnabled,
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'library'.tr(),
|
||||
label: context.t.library$,
|
||||
icon: const Icon(Icons.space_dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor),
|
||||
enabled: !isReadonlyModeEnabled,
|
||||
|
|
@ -132,7 +131,6 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
|
|||
// Library page
|
||||
if (index == kLibraryTabIndex) {
|
||||
ref.invalidate(localAlbumProvider);
|
||||
ref.invalidate(driftGetAllPeopleProvider);
|
||||
}
|
||||
|
||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'dart:async';
|
|||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
|
@ -10,6 +9,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
|
||||
import 'package:immich_mobile/models/folder/root_folder.model.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
|
|
@ -20,6 +20,7 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
|||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
RecursiveFolder? _findFolderInStructure(RootFolder rootFolder, RecursiveFolder targetFolder) {
|
||||
for (final folder in rootFolder.subfolders) {
|
||||
|
|
@ -81,7 +82,7 @@ class FolderPage extends HookConsumerWidget {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(currentFolder.value?.name ?? tr("folders")),
|
||||
title: Text(currentFolder.value?.name ?? context.t.folders),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
actions: [IconButton(icon: const Icon(Icons.swap_vert), onPressed: onToggleSortOrder)],
|
||||
|
|
@ -96,8 +97,8 @@ class FolderPage extends HookConsumerWidget {
|
|||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) {
|
||||
ImmichToast.show(context: context, msg: "failed_to_load_folder".tr(), toastType: ToastType.error);
|
||||
return Center(child: const Text("failed_to_load_folder").tr());
|
||||
ImmichToast.show(context: context, msg: context.t.failed_to_load_folder, toastType: ToastType.error);
|
||||
return Center(child: Text(context.t.failed_to_load_folder));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -125,16 +126,16 @@ class FolderContent extends HookConsumerWidget {
|
|||
}, [folder]);
|
||||
|
||||
if (folder == null) {
|
||||
return Center(child: const Text("folder_not_found").tr());
|
||||
return Center(child: Text(context.t.folder_not_found));
|
||||
}
|
||||
|
||||
String getSubtitle(int subFolderCount) {
|
||||
if (subFolderCount > 0) {
|
||||
return "$subFolderCount ${tr("folders")}".toLowerCase();
|
||||
return "$subFolderCount ${context.t.folders}".toLowerCase();
|
||||
}
|
||||
|
||||
if (subFolderCount == 1) {
|
||||
return "1 ${tr("folder")}".toLowerCase();
|
||||
return "1 ${context.t.folder}".toLowerCase();
|
||||
}
|
||||
|
||||
return "";
|
||||
|
|
@ -147,7 +148,7 @@ class FolderContent extends HookConsumerWidget {
|
|||
child: folderRenderlist.when(
|
||||
data: (folderAssets) {
|
||||
if (folder!.subfolders.isEmpty && folderAssets.isEmpty) {
|
||||
return Center(child: const Text("empty_folder").tr());
|
||||
return Center(child: Text(context.t.empty_folder));
|
||||
}
|
||||
|
||||
return ListView(
|
||||
|
|
@ -211,8 +212,8 @@ class FolderContent extends HookConsumerWidget {
|
|||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) {
|
||||
ImmichToast.show(context: context, msg: "failed_to_load_assets".tr(), toastType: ToastType.error);
|
||||
return Center(child: const Text("failed_to_load_assets").tr());
|
||||
ImmichToast.show(context: context, msg: context.t.failed_to_load_assets, toastType: ToastType.error);
|
||||
return Center(child: Text(context.t.failed_to_load_assets));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' show useState;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/local_auth.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_registration_form.dart';
|
||||
|
|
@ -31,7 +31,7 @@ class PinAuthPage extends HookConsumerWidget {
|
|||
|
||||
context.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('biometric_auth_enabled'.tr(), style: context.textTheme.labelLarge),
|
||||
content: Text(context.t.biometric_auth_enabled, style: context.textTheme.labelLarge),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: context.colorScheme.primaryContainer,
|
||||
),
|
||||
|
|
@ -52,7 +52,7 @@ class PinAuthPage extends HookConsumerWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PinVerificationForm(
|
||||
description: 'enable_biometric_auth_description'.tr(),
|
||||
description: context.t.enable_biometric_auth_description,
|
||||
onSuccess: (pinCode) {
|
||||
Navigator.pop(buildContext);
|
||||
unawaited(registerBiometric(pinCode));
|
||||
|
|
@ -71,7 +71,7 @@ class PinAuthPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('locked_folder'.tr())),
|
||||
appBar: AppBar(title: Text(context.t.locked_folder)),
|
||||
body: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
|
|
@ -97,7 +97,7 @@ class PinAuthPage extends HookConsumerWidget {
|
|||
icon: const Icon(Icons.fingerprint, size: 28),
|
||||
onPressed: () => unawaited(enableBiometricAuth()),
|
||||
label: Text(
|
||||
'use_biometric'.tr(),
|
||||
context.t.use_biometric,
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor, fontSize: 18),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_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/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart';
|
||||
|
|
@ -36,7 +36,7 @@ class SharedLinkPage extends HookConsumerWidget {
|
|||
children: [
|
||||
Icon(Icons.link_off, size: 100, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)),
|
||||
const SizedBox(height: 20),
|
||||
const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
|
||||
Text(context.t.you_dont_have_any_shared_links, style: const TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -68,7 +68,7 @@ class SharedLinkPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("shared_link_app_bar_title").tr(), elevation: 0, centerTitle: false),
|
||||
appBar: AppBar(title: Text(context.t.shared_link_app_bar_title), elevation: 0, centerTitle: false),
|
||||
body: SafeArea(
|
||||
child: sharedLinks.widgetWhen(
|
||||
onError: (error, stackTrace) => buildNoShares(),
|
||||
|
|
|
|||
|
|
@ -427,8 +427,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
|||
return showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) => ConfirmDialog(
|
||||
title: "delete_shared_link_dialog_title",
|
||||
content: "confirm_delete_shared_link",
|
||||
title: context.t.delete_shared_link_dialog_title,
|
||||
content: context.t.confirm_delete_shared_link,
|
||||
onOk: () async {
|
||||
await ref.read(sharedLinkServiceProvider).deleteSharedLink(existingLink!.id);
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import 'dart:async';
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/utils/map_utils.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
|
@ -140,7 +140,7 @@ class _BottomBar extends StatelessWidget {
|
|||
const SizedBox(width: 15),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: selectedLatLng,
|
||||
builder: (_, value, __) =>
|
||||
builder: (_, value, _) =>
|
||||
Text("${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}"),
|
||||
),
|
||||
],
|
||||
|
|
@ -148,10 +148,7 @@ class _BottomBar extends StatelessWidget {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: onUseLocation,
|
||||
child: const Text("map_location_picker_page_use_location").tr(),
|
||||
),
|
||||
ElevatedButton(onPressed: onUseLocation, child: Text(context.t.map_location_picker_page_use_location)),
|
||||
ElevatedButton(onPressed: onGetCurrentLocation, child: const Icon(Icons.my_location)),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
@ -12,7 +12,7 @@ class SyncStatusPage extends StatelessWidget {
|
|||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
title: const Text("sync_status").t(context: context),
|
||||
title: Text(context.t.sync_status),
|
||||
leading: IconButton(
|
||||
onPressed: () => context.maybePop(true),
|
||||
splashRadius: 24,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
|
|
@ -58,7 +58,7 @@ class ShareIntentPage extends ConsumerWidget {
|
|||
appBar: AppBar(
|
||||
title: Column(
|
||||
children: [
|
||||
const Text('upload_to_immich').tr(namedArgs: {'count': candidates.length.toString()}),
|
||||
Text(context.t.upload_to_immich(count: candidates.length)),
|
||||
Text(
|
||||
currentEndpoint,
|
||||
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
||||
|
|
@ -130,7 +130,7 @@ class ShareIntentPage extends ConsumerWidget {
|
|||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: (isUploading || isUploaded) ? null : upload,
|
||||
child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : const Text('upload').tr(),
|
||||
child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : Text(context.t.upload),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -149,9 +149,7 @@ class UploadingText extends StatelessWidget {
|
|||
return element.status == UploadStatus.complete;
|
||||
}).length;
|
||||
|
||||
return const Text(
|
||||
"shared_intent_upload_button_progress_text",
|
||||
).tr(namedArgs: {'current': uploadedCount.toString(), 'total': candidates.length.toString()});
|
||||
return Text(context.t.shared_intent_upload_button_progress_text(current: uploadedCount, total: candidates.length));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +166,7 @@ class UploadStatusIcon extends StatelessWidget {
|
|||
return Icon(
|
||||
Icons.check_circle_outline_rounded,
|
||||
color: context.colorScheme.onSurface.withAlpha(100),
|
||||
semanticLabel: 'not_selected'.tr(),
|
||||
semanticLabel: context.t.not_selected,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +174,7 @@ class UploadStatusIcon extends StatelessWidget {
|
|||
UploadStatus.enqueued => Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: context.primaryColor,
|
||||
semanticLabel: 'enqueued'.tr(),
|
||||
semanticLabel: context.t.enqueued,
|
||||
),
|
||||
UploadStatus.running => Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
|
|
@ -191,7 +189,7 @@ class UploadStatusIcon extends StatelessWidget {
|
|||
backgroundColor: context.colorScheme.surfaceContainerLow,
|
||||
strokeWidth: 3,
|
||||
value: value,
|
||||
semanticsLabel: 'uploading'.tr(),
|
||||
semanticsLabel: context.t.uploading,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -201,8 +199,12 @@ class UploadStatusIcon extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
UploadStatus.complete => Icon(Icons.check_circle_rounded, color: Colors.green, semanticLabel: 'completed'.tr()),
|
||||
UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()),
|
||||
UploadStatus.complete => Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.green,
|
||||
semanticLabel: context.t.completed,
|
||||
),
|
||||
UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: context.t.failed),
|
||||
};
|
||||
|
||||
return statusIcon;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
|
|||
|
||||
final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
// Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting.
|
||||
final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
|
||||
final trash =
|
||||
ownedRemote.isEmpty || (trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked));
|
||||
|
||||
return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash);
|
||||
}, dependencies: [assetsActionProvider]);
|
||||
|
|
@ -127,8 +128,11 @@ class DeleteAction extends AssetActionBuilder {
|
|||
final assetService = ref.read(assetServiceProvider);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
const ConfirmDialog(title: 'delete_dialog_title', content: 'delete_dialog_alert', ok: 'delete_permanently'),
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: context.t.delete_dialog_title,
|
||||
content: context.t.delete_dialog_alert,
|
||||
ok: context.t.delete_permanently,
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/server_capability.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
|
|
@ -16,12 +17,9 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
|
|||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/error_handler.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
const _minimumServerVersion = SemVer(major: 2, minor: 6, patch: 0);
|
||||
|
||||
final _stateProvider = Provider.family.autoDispose<RemoteAsset?, ActionSource>((ref, source) {
|
||||
final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion >= _minimumServerVersion));
|
||||
final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion.supports(.assetEdits)));
|
||||
if (!isSupported) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ class CleanupPreviewPage extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})),
|
||||
title: Text(context.t.cleanup_preview_title(count: assets.length)),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
|
|
@ -56,9 +56,9 @@ class LocalMediaSummaryPage extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('local_media_summary'.tr())),
|
||||
appBar: AppBar(title: Text(context.t.local_media_summary)),
|
||||
body: Consumer(
|
||||
builder: (ctx, ref, __) {
|
||||
builder: (ctx, ref, _) {
|
||||
final db = ref.watch(driftProvider);
|
||||
final albumsFuture = ref.watch(localAlbumRepository).getAll();
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
|
|||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
|
||||
child: Text(ctx.t.album_summary, style: ctx.textTheme.titleMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -136,9 +136,9 @@ class RemoteMediaSummaryPage extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('remote_media_summary'.tr())),
|
||||
appBar: AppBar(title: Text(context.t.remote_media_summary)),
|
||||
body: Consumer(
|
||||
builder: (ctx, ref, __) {
|
||||
builder: (ctx, ref, _) {
|
||||
final db = ref.watch(driftProvider);
|
||||
final albumsFuture = ref.watch(remoteAlbumRepository).getAll();
|
||||
|
||||
|
|
@ -159,7 +159,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
|
|||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
|
||||
child: Text(ctx.t.album_summary, style: ctx.textTheme.titleMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/pages/common/download_panel.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||
|
||||
|
|
@ -21,10 +21,7 @@ class DownloadInfoPage extends ConsumerWidget {
|
|||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("download".t(context: context)),
|
||||
actions: const [],
|
||||
),
|
||||
appBar: AppBar(title: Text(context.t.download), actions: const []),
|
||||
body: ListView.builder(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
|
|
@ -49,7 +46,7 @@ class DownloadInfoPage extends ConsumerWidget {
|
|||
},
|
||||
style: OutlinedButton.styleFrom(side: BorderSide(color: context.colorScheme.primary)),
|
||||
child: Text(
|
||||
'clear_all'.t(context: context),
|
||||
context.t.clear_all,
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
|||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
|
|
@ -32,12 +32,14 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||
final userId = ref.watch(authProvider).userId;
|
||||
final activityEnabled = useState(album.isActivityEnabled);
|
||||
final isOwner = album.ownerId == userId;
|
||||
final owner = isOwner ? ref.watch(currentUserProvider) : null;
|
||||
final allUsers = isOwner ? null : ref.watch(driftUsersProvider);
|
||||
|
||||
void showErrorMessage() {
|
||||
ContextHelper(context).pop();
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "shared_album_section_people_action_error".t(context: context),
|
||||
msg: context.t.shared_album_section_people_action_error,
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
|
|
@ -87,7 +89,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "users_added_to_album_count".t(context: context, args: {'count': newUsers.length}),
|
||||
msg: context.t.users_added_to_album_count(count: newUsers.length),
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
} catch (e) {
|
||||
|
|
@ -106,7 +108,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||
actions = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.exit_to_app_rounded),
|
||||
title: const Text("leave_album").t(context: context),
|
||||
title: Text(context.t.leave_album),
|
||||
onTap: leaveAlbum,
|
||||
),
|
||||
];
|
||||
|
|
@ -116,7 +118,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||
actions = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_remove_rounded),
|
||||
title: const Text("remove_user").t(context: context),
|
||||
title: Text(context.t.remove_user),
|
||||
onTap: () => removeUserFromAlbum(user),
|
||||
),
|
||||
];
|
||||
|
|
@ -141,16 +143,18 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||
|
||||
Widget buildOwnerInfo() {
|
||||
if (isOwner) {
|
||||
final owner = ref.read(currentUserProvider);
|
||||
return ListTile(
|
||||
leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(),
|
||||
title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(owner?.email ?? "", style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
|
||||
trailing: Text(context.t.owner, style: context.textTheme.labelLarge),
|
||||
);
|
||||
} else {
|
||||
final usersProvider = ref.read(driftUsersProvider);
|
||||
return usersProvider.maybeWhen(
|
||||
if (allUsers == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return allUsers.maybeWhen(
|
||||
data: (users) {
|
||||
final user = users.firstWhereOrNull((u) => u.id == album.ownerId);
|
||||
|
||||
|
|
@ -162,7 +166,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||
leading: UserCircleAvatar(user: user),
|
||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
|
||||
trailing: Text(context.t.owner, style: context.textTheme.labelLarge),
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox(),
|
||||
|
|
@ -207,7 +211,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||
onPressed: () => context.maybePop(null),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: Text("options".t(context: context)),
|
||||
title: Text(context.t.options),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
|
|
@ -222,19 +226,19 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"comments_and_likes",
|
||||
context.t.comments_and_likes,
|
||||
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
).t(context: context),
|
||||
),
|
||||
subtitle: Text(
|
||||
"let_others_respond",
|
||||
context.t.let_others_respond,
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
).t(context: context),
|
||||
),
|
||||
),
|
||||
buildSectionTitle("shared_album_section_people_title".t(context: context)),
|
||||
buildSectionTitle(context.t.shared_album_section_people_title),
|
||||
if (isOwner) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add_rounded),
|
||||
title: Text("invite_people".t(context: context)),
|
||||
title: Text(context.t.invite_people),
|
||||
onTap: () async => addUsers(),
|
||||
),
|
||||
const Divider(indent: 16),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
|
@ -28,10 +28,7 @@ class DriftArchivePage extends StatelessWidget {
|
|||
}),
|
||||
],
|
||||
child: Timeline(
|
||||
appBar: MesmerizingSliverAppBar(
|
||||
title: 'archive'.t(context: context),
|
||||
icon: Icons.archive_outlined,
|
||||
),
|
||||
appBar: MesmerizingSliverAppBar(title: context.t.archive, icon: Icons.archive_outlined),
|
||||
bottomSheet: const ArchiveBottomSheet(),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
@ -18,7 +18,7 @@ class AssetTroubleshootPage extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('asset_troubleshoot'.tr())),
|
||||
appBar: AppBar(title: Text(context.t.asset_troubleshoot)),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
|
|
@ -42,7 +42,7 @@ class _AssetDetailsView extends StatelessWidget {
|
|||
_AssetPropertiesSection(asset: asset),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'matching_assets'.tr(),
|
||||
context.t.matching_assets,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (asset.checksum != null) ...[
|
||||
|
|
@ -52,12 +52,12 @@ class _AssetDetailsView extends StatelessWidget {
|
|||
] else ...[
|
||||
_PropertySectionCard(
|
||||
title: 'Local Assets',
|
||||
properties: [_PropertyItem(label: 'Status', value: 'no_checksum_local'.tr())],
|
||||
properties: [_PropertyItem(label: 'Status', value: context.t.no_checksum_local)],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PropertySectionCard(
|
||||
title: 'Remote Assets',
|
||||
properties: [_PropertyItem(label: 'Status', value: 'no_checksum_remote'.tr())],
|
||||
properties: [_PropertyItem(label: 'Status', value: context.t.no_checksum_remote)],
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
@ -243,7 +243,7 @@ class _LocalAssetsSection extends ConsumerWidget {
|
|||
if (localAssets.isEmpty) {
|
||||
return _PropertySectionCard(
|
||||
title: 'Local Assets',
|
||||
properties: [_PropertyItem(label: 'Status', value: 'no_local_assets_found'.tr())],
|
||||
properties: [_PropertyItem(label: 'Status', value: context.t.no_local_assets_found)],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +302,7 @@ class _RemoteAssetSection extends ConsumerWidget {
|
|||
if (remoteAsset == null) {
|
||||
return _PropertySectionCard(
|
||||
title: 'Remote Assets',
|
||||
properties: [_PropertyItem(label: 'Status', value: 'no_remote_assets_found'.tr())],
|
||||
properties: [_PropertyItem(label: 'Status', value: context.t.no_remote_assets_found)],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -356,7 +356,7 @@ class _PropertyItem extends StatelessWidget {
|
|||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value ?? 'not_available'.tr(),
|
||||
value ?? context.t.not_available,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.secondary),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
|
@ -52,9 +52,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty && !isCreatingAlbum;
|
||||
|
||||
String _getEffectiveTitle() {
|
||||
return albumTitleController.text.isNotEmpty
|
||||
? albumTitleController.text
|
||||
: 'create_album_page_untitled'.t(context: context);
|
||||
return albumTitleController.text.isNotEmpty ? albumTitleController.text : context.t.create_album_page_untitled;
|
||||
}
|
||||
|
||||
Widget _buildSliverAppBar() {
|
||||
|
|
@ -93,7 +91,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||
Widget _buildEmptyState() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 0, left: 18),
|
||||
child: Text('create_shared_album_page_share_add_assets', style: context.textTheme.labelLarge).t(),
|
||||
child: Text(context.t.create_shared_album_page_share_add_assets, style: context.textTheme.labelLarge),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -112,9 +110,9 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||
label: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
'create_shared_album_page_share_select_photos',
|
||||
context.t.create_shared_album_page_share_select_photos,
|
||||
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
).t(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -148,7 +146,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||
});
|
||||
|
||||
if (albumTitleController.text.isEmpty) {
|
||||
final untitledText = 'create_album_page_untitled'.t();
|
||||
final untitledText = context.t.create_album_page_untitled;
|
||||
albumTitleController.text = untitledText;
|
||||
}
|
||||
}
|
||||
|
|
@ -196,7 +194,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t());
|
||||
ImmichToast.show(context: context, toastType: ToastType.error, msg: context.t.errors.failed_to_create_album);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => isCreatingAlbum = false);
|
||||
|
|
@ -241,7 +239,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||
AlbumActionFilledButton(
|
||||
iconData: Icons.add_photo_alternate_outlined,
|
||||
onPressed: onSelectPhotos,
|
||||
labelText: "add_photos".t(),
|
||||
labelText: context.t.add_photos,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -257,12 +255,12 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||
centerTitle: false,
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.close_rounded)),
|
||||
title: const Text('create_album').t(),
|
||||
title: Text(context.t.create_album),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _canCreateAlbum ? createAlbum : null,
|
||||
child: Text(
|
||||
'create'.t(),
|
||||
context.t.create,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _canCreateAlbum ? context.primaryColor : context.themeData.disabledColor,
|
||||
|
|
@ -320,7 +318,7 @@ class _AlbumTitleTextFieldState extends State<_AlbumTitleTextField> {
|
|||
style: TextStyle(fontSize: 28.0, color: context.colorScheme.onSurface, fontWeight: FontWeight.bold),
|
||||
controller: widget.textController,
|
||||
onTap: () {
|
||||
if (widget.textController.text == 'create_album_page_untitled'.t(context: context)) {
|
||||
if (widget.textController.text == context.t.create_album_page_untitled) {
|
||||
widget.textController.clear();
|
||||
}
|
||||
},
|
||||
|
|
@ -343,7 +341,7 @@ class _AlbumTitleTextFieldState extends State<_AlbumTitleTextField> {
|
|||
borderSide: BorderSide(color: context.primaryColor.withValues(alpha: 0.3)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
),
|
||||
hintText: 'add_a_title'.t(),
|
||||
hintText: context.t.add_a_title,
|
||||
hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith(
|
||||
fontSize: 28.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -430,7 +428,7 @@ class _AlbumViewerEditableDescriptionState extends State<_AlbumViewerEditableDes
|
|||
focusColor: Colors.grey[300],
|
||||
fillColor: context.scaffoldBackgroundColor,
|
||||
filled: widget.focusNode.hasFocus,
|
||||
hintText: 'add_a_description'.t(),
|
||||
hintText: context.t.add_a_description,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
|
@ -28,10 +28,7 @@ class DriftFavoritePage extends StatelessWidget {
|
|||
}),
|
||||
],
|
||||
child: Timeline(
|
||||
appBar: MesmerizingSliverAppBar(
|
||||
title: 'favorites'.t(context: context),
|
||||
icon: Icons.favorite_outline,
|
||||
),
|
||||
appBar: MesmerizingSliverAppBar(title: context.t.favorites, icon: Icons.favorite_outline),
|
||||
bottomSheet: const FavoriteBottomSheet(),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||
|
|
@ -56,13 +55,13 @@ class _ActionButtonGrid extends ConsumerWidget {
|
|||
_ActionButton(
|
||||
icon: Icons.favorite_outline_rounded,
|
||||
onTap: () => context.pushRoute(const DriftFavoriteRoute()),
|
||||
label: 'favorites'.t(context: context),
|
||||
label: context.t.favorites,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ActionButton(
|
||||
icon: Icons.archive_outlined,
|
||||
onTap: () => context.pushRoute(const DriftArchiveRoute()),
|
||||
label: 'archived'.t(context: context),
|
||||
label: context.t.archived,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -72,14 +71,14 @@ class _ActionButtonGrid extends ConsumerWidget {
|
|||
_ActionButton(
|
||||
icon: Icons.link_outlined,
|
||||
onTap: () => context.pushRoute(const SharedLinkRoute()),
|
||||
label: 'shared_links'.t(context: context),
|
||||
label: context.t.shared_links,
|
||||
),
|
||||
isTrashEnable ? const SizedBox(width: 8) : const SizedBox.shrink(),
|
||||
isTrashEnable
|
||||
? _ActionButton(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
onTap: () => context.pushRoute(const DriftTrashRoute()),
|
||||
label: 'trash'.t(context: context),
|
||||
label: context.t.trash,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
|
|
@ -146,7 +145,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final people = ref.watch(driftGetAllPeopleProvider);
|
||||
final people = ref.watch(getAllPeopleProvider);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
|
@ -193,7 +192,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'people'.t(context: context),
|
||||
context.t.people,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
@ -245,7 +244,7 @@ class _PlacesCollectionCard extends StatelessWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'places'.t(),
|
||||
context.t.places,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
@ -303,9 +302,7 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
|
|||
}).toList();
|
||||
},
|
||||
error: (error, _) {
|
||||
return [
|
||||
Center(child: Text('error_saving_image'.tr(args: [error.toString()]))),
|
||||
];
|
||||
return [Center(child: Text(context.t.error_saving_image(error: error.toString())))];
|
||||
},
|
||||
loading: () {
|
||||
return [const Center(child: CircularProgressIndicator())];
|
||||
|
|
@ -317,7 +314,7 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'on_this_device'.t(context: context),
|
||||
context.t.on_this_device,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
@ -384,7 +381,7 @@ class _QuickAccessButtonList extends ConsumerWidget {
|
|||
),
|
||||
leading: const Icon(Icons.folder_outlined, size: 26),
|
||||
title: Text(
|
||||
'folders'.t(context: context),
|
||||
context.t.folders,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
onTap: () => context.pushRoute(FolderRoute()),
|
||||
|
|
@ -392,7 +389,7 @@ class _QuickAccessButtonList extends ConsumerWidget {
|
|||
ListTile(
|
||||
leading: const Icon(Icons.lock_outline_rounded, size: 26),
|
||||
title: Text(
|
||||
'locked_folder'.t(context: context),
|
||||
context.t.locked_folder,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
onTap: () => context.pushRoute(const DriftLockedFolderRoute()),
|
||||
|
|
@ -400,7 +397,7 @@ class _QuickAccessButtonList extends ConsumerWidget {
|
|||
ListTile(
|
||||
leading: const Icon(Icons.group_outlined, size: 26),
|
||||
title: Text(
|
||||
'partners'.t(context: context),
|
||||
context.t.partners,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
onTap: () => context.pushRoute(const PartnerRoute()),
|
||||
|
|
@ -438,10 +435,10 @@ class _PartnerList extends StatelessWidget {
|
|||
),
|
||||
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
|
||||
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
||||
title: const Text(
|
||||
"partner_list_user_photos",
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
).t(context: context, args: {'user': partner.name}),
|
||||
title: Text(
|
||||
context.t.partner_list_user_photos(user: partner.name),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
onTap: () => context.pushRoute(DriftPartnerDetailRoute(partner: partner)),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
|
|
@ -49,7 +48,7 @@ class _AlbumList extends ConsumerWidget {
|
|||
if (albums.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('no_albums_yet'.tr())),
|
||||
child: Padding(padding: const EdgeInsets.all(20.0), child: Text(context.t.no_albums_yet)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -67,7 +66,7 @@ class _AlbumList extends ConsumerWidget {
|
|||
leading: SizedBox(width: 80, height: 80, child: LocalAlbumThumbnail(albumId: album.id)),
|
||||
title: Text(album.name, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
'items_count'.t(context: context, args: {'count': album.assetCount}),
|
||||
context.t.items_count(count: album.assetCount),
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onTap: () => context.pushRoute(LocalTimelineRoute(album: album)),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'dart:async';
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
|
|
@ -70,7 +70,7 @@ class _DriftLockedFolderPageState extends ConsumerState<DriftLockedFolderPage> w
|
|||
: PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) => didPop ? ref.read(authProvider.notifier).lockPinCode() : null,
|
||||
child: Timeline(
|
||||
appBar: MesmerizingSliverAppBar(title: 'locked_folder'.t(context: context)),
|
||||
appBar: MesmerizingSliverAppBar(title: context.t.locked_folder),
|
||||
bottomSheet: const LockedFolderBottomSheet(),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
|
||||
|
|
@ -248,7 +248,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
final yearsAgo = DateTime.now().year - memories[mIndex].data.year;
|
||||
final title = 'years_ago'.t(context: context, args: {'years': yearsAgo.toString()});
|
||||
final title = context.t.years_ago(years: yearsAgo);
|
||||
// Build horizontal page
|
||||
final assetController = memoryAssetPageControllers[mIndex];
|
||||
return Column(
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
|
@ -33,7 +34,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final people = ref.watch(driftGetAllPeopleProvider);
|
||||
final people = ref.watch(getAllPeopleProvider);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
|
@ -49,10 +50,10 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
|||
onTapOutside: (_) => _formFocus.unfocus(),
|
||||
onChanged: (value) => setState(() => _search = value),
|
||||
filled: true,
|
||||
hintText: 'filter_people'.tr(),
|
||||
hintText: context.t.filter_people,
|
||||
autofocus: true,
|
||||
)
|
||||
: Text('people'.tr()),
|
||||
: Text(context.t.people),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_search != null ? Icons.close : Icons.search),
|
||||
|
|
@ -65,12 +66,15 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
|||
body: SafeArea(
|
||||
child: people.when(
|
||||
data: (people) {
|
||||
final List<Person> filtered;
|
||||
if (_search != null) {
|
||||
people = people.where((person) {
|
||||
filtered = people.where((person) {
|
||||
return person.name.toLowerCase().removeDiacritics().contains(
|
||||
_search!.toLowerCase().removeDiacritics(),
|
||||
);
|
||||
}).toList();
|
||||
} else {
|
||||
filtered = people;
|
||||
}
|
||||
return GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
|
|
@ -79,9 +83,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
|||
mainAxisSpacing: isPortrait && isTablet ? 36 : 0,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
itemCount: people.length,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = people[index];
|
||||
final person = filtered[index];
|
||||
|
||||
return Column(
|
||||
key: ValueKey(person.id),
|
||||
|
|
@ -107,7 +111,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
|||
onTap: () => showNameEditModal(context, person),
|
||||
child: person.name.isEmpty
|
||||
? Text(
|
||||
'add_a_name'.tr(),
|
||||
context.t.add_a_name,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: context.colorScheme.primary,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart';
|
|||
|
||||
@RoutePage()
|
||||
class DriftPersonPage extends ConsumerStatefulWidget {
|
||||
final DriftPerson person;
|
||||
final Person person;
|
||||
|
||||
const DriftPersonPage({super.key, required this.person});
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ class DriftPersonPage extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
|
||||
late DriftPerson _person;
|
||||
late Person _person;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
|
@ -62,10 +62,10 @@ class _PlaceSliverAppBar extends HookWidget {
|
|||
onTapOutside: (_) => searchFocusNode.unfocus(),
|
||||
onChanged: (value) => search.value = value,
|
||||
filled: true,
|
||||
hintText: 'filter_places'.t(context: context),
|
||||
hintText: context.t.filter_places,
|
||||
autofocus: true,
|
||||
)
|
||||
: Text('places'.t(context: context)),
|
||||
: Text(context.t.places),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(search.value != null ? Icons.close : Icons.search),
|
||||
|
|
@ -94,7 +94,7 @@ class _Map extends StatelessWidget {
|
|||
height: 200,
|
||||
width: context.width,
|
||||
child: MapThumbnail(
|
||||
onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
|
||||
onTap: (_, _) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
|
||||
zoom: 8,
|
||||
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
|
||||
showAttribution: false,
|
||||
|
|
@ -134,16 +134,19 @@ class _PlaceList extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
data: (places) {
|
||||
final List<(String, String)> filtered;
|
||||
if (search.value != null) {
|
||||
places = places.where((place) {
|
||||
filtered = places.where((place) {
|
||||
return place.$1.toLowerCase().contains(search.value!.toLowerCase());
|
||||
}).toList();
|
||||
} else {
|
||||
filtered = places;
|
||||
}
|
||||
|
||||
return SliverList.builder(
|
||||
itemCount: places.length,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final place = places[index];
|
||||
final place = filtered[index];
|
||||
return _PlaceTile(place: place);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.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/user.provider.dart';
|
||||
|
|
@ -26,7 +26,7 @@ class DriftRecentlyAddedPage extends StatelessWidget {
|
|||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: Timeline(appBar: MesmerizingSliverAppBar(title: 'recently_added'.t())),
|
||||
child: Timeline(appBar: MesmerizingSliverAppBar(title: context.t.recently_added)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.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/user.provider.dart';
|
||||
|
|
@ -26,7 +26,7 @@ class DriftRecentlyTakenPage extends StatelessWidget {
|
|||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: Timeline(appBar: MesmerizingSliverAppBar(title: 'recently_taken'.t())),
|
||||
child: Timeline(appBar: MesmerizingSliverAppBar(title: context.t.recently_taken)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.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/remote_album/drift_album_option.widget.dart';
|
||||
|
|
@ -62,7 +62,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
if (added > 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}),
|
||||
msg: context.t.assets_added_to_album_count(count: added),
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "users_added_to_album_count".t(context: context, args: {'count': newUsers.length}),
|
||||
msg: context.t.users_added_to_album_count(count: newUsers.length),
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
} catch (e) {
|
||||
|
|
@ -111,24 +111,21 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('delete_album'.t(context: context)),
|
||||
title: Text(context.t.delete_album),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('album_delete_confirmation'.t(context: context, args: {'album': _album.name})),
|
||||
Text(context.t.album_delete_confirmation(album: _album.name)),
|
||||
const SizedBox(height: 8),
|
||||
Text('album_delete_confirmation_description'.t(context: context)),
|
||||
Text(context.t.album_delete_confirmation_description),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('cancel'.t(context: context)),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(context.t.cancel)),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
|
||||
child: Text('delete_album'.t(context: context)),
|
||||
child: Text(context.t.delete_album),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -146,11 +143,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'album_deleted'.t(context: context),
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
ImmichToast.show(context: context, msg: context.t.album_deleted, toastType: ToastType.success);
|
||||
|
||||
unawaited(context.pushRoute(const DriftAlbumsRoute()));
|
||||
} catch (e) {
|
||||
|
|
@ -160,7 +153,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'album_viewer_appbar_share_err_delete'.t(context: context),
|
||||
msg: context.t.album_viewer_appbar_share_err_delete,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
|
@ -288,11 +281,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
|||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'album_update_error'.t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
ImmichToast.show(context: context, msg: context.t.errors.unable_to_update_album_info, toastType: ToastType.error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -315,14 +304,14 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
|||
children: [
|
||||
Icon(Icons.edit_outlined, color: context.colorScheme.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Text('edit_album'.t(context: context), style: context.textTheme.titleMedium),
|
||||
Text(context.t.edit_album, style: context.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Album Name
|
||||
Text(
|
||||
'album_name'.t(context: context).toUpperCase(),
|
||||
context.t.album_name.toUpperCase(),
|
||||
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
|
@ -337,7 +326,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
|||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'album_name_required'.t(context: context);
|
||||
return context.t.name_required;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -347,7 +336,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
|||
|
||||
// Description
|
||||
Text(
|
||||
'description'.t(context: context).toUpperCase(),
|
||||
context.t.description.toUpperCase(),
|
||||
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
|
@ -367,15 +356,9 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: Text('cancel'.t(context: context)),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(context.t.cancel)),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: _handleSave,
|
||||
child: Text('save'.t(context: context)),
|
||||
),
|
||||
FilledButton(onPressed: _handleSave, child: Text(context.t.save)),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ 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/asset/base_asset.model.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/generated/translations.g.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';
|
||||
|
|
@ -295,14 +296,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
|||
color: context.colorScheme.primary,
|
||||
);
|
||||
} else {
|
||||
return LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: BorderRadius.zero,
|
||||
minHeight: 5,
|
||||
value:
|
||||
ref.read(videoPlayerProvider(asset.id).select((s) => s.position)).inMilliseconds /
|
||||
asset.duration.inMilliseconds,
|
||||
);
|
||||
return _VideoProgressBar(asset: asset);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,25 +368,13 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
|||
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
|
||||
);
|
||||
} else {
|
||||
final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status));
|
||||
final position = ref.read(videoPlayerProvider(asset.id)).position;
|
||||
|
||||
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
|
||||
unawaited(_nextPage());
|
||||
} else if (status == VideoPlaybackStatus.playing) {
|
||||
unawaited(ref.read(videoPlayerProvider(asset.id).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),
|
||||
),
|
||||
return _VideoChild(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
scale: scale,
|
||||
imageProvider: imageProvider,
|
||||
onTapUp: _onTapUp,
|
||||
onCompleted: _nextPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -411,7 +393,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
|||
children: [
|
||||
AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text("slideshow".t(context: context)),
|
||||
title: Text(context.t.slideshow),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _paused ? _play : _pause,
|
||||
|
|
@ -477,6 +459,67 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
|||
}
|
||||
}
|
||||
|
||||
class _VideoChild extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final bool isCurrent;
|
||||
final PhotoViewComputedScale scale;
|
||||
final ImageProvider imageProvider;
|
||||
final VoidCallback onTapUp;
|
||||
final VoidCallback onCompleted;
|
||||
|
||||
const _VideoChild({
|
||||
required this.asset,
|
||||
required this.isCurrent,
|
||||
required this.scale,
|
||||
required this.imageProvider,
|
||||
required this.onTapUp,
|
||||
required this.onCompleted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) {
|
||||
if (status == VideoPlaybackStatus.completed) {
|
||||
if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) {
|
||||
onCompleted();
|
||||
}
|
||||
} else if (status == VideoPlaybackStatus.playing) {
|
||||
unawaited(ref.read(videoPlayerProvider(asset.id).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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoProgressBar extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const _VideoProgressBar({required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position));
|
||||
|
||||
return LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: BorderRadius.zero,
|
||||
minHeight: 5,
|
||||
value: position.inMilliseconds / asset.duration.inMilliseconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress bar for image slides, driven by an explicit [AnimationController].
|
||||
///
|
||||
/// [TweenAnimationBuilder] creates its controller internally with the default
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart';
|
||||
|
|
@ -35,7 +34,7 @@ class DriftTrashPage extends StatelessWidget {
|
|||
],
|
||||
child: Timeline(
|
||||
appBar: SliverAppBar(
|
||||
title: Text('trash'.t(context: context)),
|
||||
title: Text(context.t.trash),
|
||||
floating: true,
|
||||
snap: true,
|
||||
pinned: true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
|
@ -9,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
|||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
|
@ -52,6 +52,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<List<UserDto>> suggestedShareUsers = ref.watch(driftUsersProvider);
|
||||
final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id));
|
||||
final sharedUsersList = useState<Set<UserDto>>({});
|
||||
|
||||
void addNewUsersHandler() {
|
||||
|
|
@ -86,7 +87,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'suggestions'.tr(),
|
||||
context.t.suggestions,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
|
@ -118,7 +119,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('invite_to_album').tr(),
|
||||
title: Text(context.t.invite_to_album),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
leading: IconButton(
|
||||
|
|
@ -130,15 +131,12 @@ class DriftUserSelectionPage extends HookConsumerWidget {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: sharedUsersList.value.isEmpty ? null : () => addNewUsersHandler(),
|
||||
child: const Text("add", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
|
||||
child: Text(context.t.add, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: suggestedShareUsers.widgetWhen(
|
||||
onData: (users) {
|
||||
// Get shared users for this album from the database
|
||||
final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id));
|
||||
|
||||
return sharedUsers.when(
|
||||
data: (albumSharedUsers) {
|
||||
// Filter out users that are already shared with this album and the owner
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.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/user.provider.dart';
|
||||
|
|
@ -26,7 +26,7 @@ class DriftVideoPage extends StatelessWidget {
|
|||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: Timeline(appBar: MesmerizingSliverAppBar(title: 'videos'.t())),
|
||||
child: Timeline(appBar: MesmerizingSliverAppBar(title: context.t.videos)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/constants/aspect_ratios.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
|
|
@ -63,14 +64,14 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
|||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(context: context, msg: 'success'.tr(), toastType: ToastType.success);
|
||||
ImmichToast.show(context: context, msg: context.t.success, toastType: ToastType.success);
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(context: context, msg: 'error_title'.tr(), toastType: ToastType.error);
|
||||
ImmichToast.show(context: context, msg: context.t.error_title, toastType: ToastType.error);
|
||||
} finally {
|
||||
ref.read(editorStateProvider.notifier).setIsEditing(false);
|
||||
}
|
||||
|
|
@ -80,17 +81,17 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
|||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('editor_discard_edits_title'.tr()),
|
||||
content: Text('editor_discard_edits_prompt'.tr()),
|
||||
title: Text(context.t.editor_discard_edits_title),
|
||||
content: Text(context.t.editor_discard_edits_prompt),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
style: ButtonStyle(
|
||||
foregroundColor: WidgetStateProperty.all(context.themeData.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
child: Text('cancel'.tr()),
|
||||
child: Text(context.t.cancel),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text('confirm'.tr())),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text(context.t.confirm)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -116,7 +117,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
|||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: Text("edit".tr()),
|
||||
title: Text(context.t.edit),
|
||||
leading: ImmichCloseButton(onPressed: () => Navigator.of(context).maybePop()),
|
||||
actions: [_SaveEditsButton(onSave: _saveEditedImage)],
|
||||
),
|
||||
|
|
@ -336,7 +337,7 @@ class _ResetEditsButton extends ConsumerWidget {
|
|||
final editorNotifier = ref.watch(editorStateProvider.notifier);
|
||||
|
||||
return ImmichTextButton(
|
||||
labelText: 'reset'.tr(),
|
||||
labelText: context.t.reset,
|
||||
onPressed: editorNotifier.resetEdits,
|
||||
variant: ImmichVariant.ghost,
|
||||
expanded: false,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue