mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge branch 'main' into feat/album-asset-workflow-trigger
This commit is contained in:
commit
927a8a8dcf
350 changed files with 4864 additions and 6903 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,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')
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -97,6 +97,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
|
||||
|
|
|
|||
|
|
@ -33,12 +33,28 @@ data class Request(
|
|||
val callback: (Result<Map<String, Long>?>) -> Unit
|
||||
)
|
||||
|
||||
/** Set [exactSize] to decode at the smallest size that covers [target] without upscaling. */
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
||||
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0), exactSize: Boolean = false): Bitmap {
|
||||
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
|
||||
if (target.width > 0 && target.height > 0) {
|
||||
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
|
||||
decoder.setTargetSampleSize(sample)
|
||||
if (exactSize) {
|
||||
val fillScale = max(
|
||||
target.width.toDouble() / info.size.width,
|
||||
target.height.toDouble() / info.size.height
|
||||
)
|
||||
val scale = min(1.0, fillScale)
|
||||
if (scale < 1) {
|
||||
val width = ceil(info.size.width * scale).toInt()
|
||||
val height = ceil(info.size.height * scale).toInt()
|
||||
if (width > 0 && height > 0) {
|
||||
decoder.setTargetSize(width, height)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
|
||||
decoder.setTargetSampleSize(sample)
|
||||
}
|
||||
}
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import android.graphics.ImageDecoder
|
|||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import android.util.Size
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
import app.alextran.immich.NativeBuffer
|
||||
|
|
@ -79,6 +80,8 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||
url: String,
|
||||
requestId: Long,
|
||||
preferEncoded: Boolean,
|
||||
width: Long?,
|
||||
height: Long?,
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
|
|
@ -102,13 +105,21 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
decodeExecutor.execute {
|
||||
val res = if (signal.isCanceled) null else try {
|
||||
val bitmap = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset)).decodeBitmap()
|
||||
// The embedded preview a raw decodes to has no orientation, so read the container's.
|
||||
val orientation = if (isRawMime(contentType)) {
|
||||
readRawOrientation(NativeBuffer.wrap(buffer.pointer, buffer.offset), buffer.offset)
|
||||
} else {
|
||||
ExifInterface.ORIENTATION_NORMAL
|
||||
}
|
||||
val target = when (orientation) {
|
||||
ExifInterface.ORIENTATION_TRANSPOSE,
|
||||
ExifInterface.ORIENTATION_ROTATE_90,
|
||||
ExifInterface.ORIENTATION_TRANSVERSE,
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> Size(height?.toInt() ?: 0, width?.toInt() ?: 0)
|
||||
else -> Size(width?.toInt() ?: 0, height?.toInt() ?: 0)
|
||||
}
|
||||
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
|
||||
val bitmap = source.decodeBitmap(target, exactSize = true)
|
||||
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
||||
bitmap.toNativeBuffer()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
101
mobile/integration_test/failed_sync_resume_e2e_test.dart
Normal file
101
mobile/integration_test/failed_sync_resume_e2e_test.dart
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/main.dart' as app;
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/wm_executor.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'test_utils/fake_immich_server.dart';
|
||||
|
||||
// Issue #28082 end-to-end: a resume after a sync froze mid-flight starts a fresh sync.
|
||||
// Kept in its own file on purpose: run after the failed_sync_resume tests in one process,
|
||||
// the resume sync starves past its window - a cross-test interaction that survived pool,
|
||||
// sqlite and bg-worker audits unnamed. One file per process is device-proven green.
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
|
||||
late Drift drift;
|
||||
late FakeImmichServer server;
|
||||
|
||||
// main()'s formula with a higher floor: a wedged frozen worker plus a full local
|
||||
// sync must not starve the fresh resume sync out of its 25s window on 4 cores.
|
||||
final poolSize = max(Platform.numberOfProcessors - 1, 8);
|
||||
|
||||
setUpAll(() async {
|
||||
await app.initApp();
|
||||
(drift, _) = await Bootstrap.initDomain();
|
||||
// A background-worker schedule persisted by real app use on this device can
|
||||
// launch a second engine mid-file (own isolate pool + full sync) and starve
|
||||
// these tests on a small device. Unregister it for the whole run.
|
||||
await BackgroundWorkerFgService(BackgroundWorkerFgHostApi()).disable();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
// A task completing while dispose tears the pool down re-warms it (_schedule
|
||||
// on the cleared pool re-creates workers), and init on a warm pool is silently
|
||||
// ignored - poolSize would never apply. Reset first, then verify it took.
|
||||
await workerManagerPatch.dispose();
|
||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: poolSize);
|
||||
expect(
|
||||
workerManagerPatch.pool.length,
|
||||
poolSize,
|
||||
reason: 'init was ignored: a straggler from the previous test re-warmed the pool',
|
||||
);
|
||||
server = await FakeImmichServer.start();
|
||||
await ApiService().resolveAndSetEndpoint(server.endpoint);
|
||||
await drift.delete(drift.userEntity).go();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
// Close the server first so any held-open sync stream ends and its isolate unwinds,
|
||||
// then drain the pool - otherwise dispose waits on the frozen read.
|
||||
await server.close();
|
||||
await workerManagerPatch.dispose();
|
||||
});
|
||||
|
||||
testWidgets('a resume after a sync froze mid-flight starts a fresh sync', (tester) async {
|
||||
final manager = BackgroundSyncManager();
|
||||
// Not disposed on purpose: driftOverride closes the drift on dispose, and that
|
||||
// drift belongs to setUpAll. The container only holds the shared drift and a
|
||||
// fire-and-forget resume; the frozen isolates + server are drained by tearDown.
|
||||
final container = ProviderContainer(
|
||||
overrides: [driftProvider.overrideWith(driftOverride(drift)), backgroundSyncProvider.overrideWithValue(manager)],
|
||||
);
|
||||
|
||||
// A first sync opens /sync/stream and never finishes - the frozen state a
|
||||
// suspended sync isolate is left in. Holding the stream open keeps
|
||||
// _syncTask non-null, exactly as it is across an iOS process suspension.
|
||||
unawaited(manager.syncRemote());
|
||||
await server
|
||||
.streamOpenedNth(1)
|
||||
.timeout(const Duration(seconds: 30), onTimeout: () => fail('first sync isolate never opened /sync/stream'));
|
||||
|
||||
// The lifecycle then goes background -> foreground. handleAppResume runs the
|
||||
// resume sync exactly once. On the buggy build it hangs on the stale task's
|
||||
// future, so it is not awaited here.
|
||||
final notifier = container.read(appStateProvider.notifier);
|
||||
await notifier.handleAppPause();
|
||||
unawaited(notifier.handleAppResume());
|
||||
|
||||
await server
|
||||
.streamOpenedNth(2)
|
||||
.timeout(
|
||||
const Duration(seconds: 25),
|
||||
onTimeout: () => fail('resume did not start a fresh remote sync - the stale frozen sync blocked it (#28082)'),
|
||||
);
|
||||
expect(server.streamOpenCount, greaterThanOrEqualTo(2));
|
||||
});
|
||||
}
|
||||
133
mobile/integration_test/failed_sync_resume_test.dart
Normal file
133
mobile/integration_test/failed_sync_resume_test.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/main.dart' as app;
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/wm_executor.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'test_utils/fake_immich_server.dart';
|
||||
|
||||
// Issue #28082: a remote sync in-flight when the app is backgrounded stays referenced
|
||||
// but frozen across the suspension. On resume the app drops the stale task
|
||||
// (cancelResumeSyncs) and starts a fresh sync.
|
||||
//
|
||||
// Device/emulator tests: real worker isolates + a real drift db + a loopback fake server
|
||||
// (same pattern as background_sync_teardown_test). The mobile integration-test CI job is
|
||||
// disabled in test.yml, so like Mert's teardown test this is a local/on-device guard.
|
||||
//
|
||||
// The end-to-end resume test lives in failed_sync_resume_e2e_test.dart - one process
|
||||
// per file keeps it clear of the cross-test interaction described there.
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
|
||||
late Drift drift;
|
||||
late FakeImmichServer server;
|
||||
|
||||
// main()'s formula with a higher floor: a wedged frozen worker plus a full local
|
||||
// sync must not starve the fresh resume sync out of its 25s window on 4 cores.
|
||||
final poolSize = max(Platform.numberOfProcessors - 1, 8);
|
||||
|
||||
setUpAll(() async {
|
||||
await app.initApp();
|
||||
(drift, _) = await Bootstrap.initDomain();
|
||||
// A background-worker schedule persisted by real app use on this device can
|
||||
// launch a second engine mid-file (own isolate pool + full sync) and starve
|
||||
// these tests on a small device. Unregister it for the whole run.
|
||||
await BackgroundWorkerFgService(BackgroundWorkerFgHostApi()).disable();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
// A task completing while dispose tears the pool down re-warms it (_schedule
|
||||
// on the cleared pool re-creates workers), and init on a warm pool is silently
|
||||
// ignored - poolSize would never apply. Reset first, then verify it took.
|
||||
await workerManagerPatch.dispose();
|
||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: poolSize);
|
||||
expect(
|
||||
workerManagerPatch.pool.length,
|
||||
poolSize,
|
||||
reason: 'init was ignored: a straggler from the previous test re-warmed the pool',
|
||||
);
|
||||
server = await FakeImmichServer.start();
|
||||
await ApiService().resolveAndSetEndpoint(server.endpoint);
|
||||
await drift.delete(drift.userEntity).go();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
// Close the server first so any held-open sync stream ends and its isolate unwinds,
|
||||
// then drain the pool - otherwise dispose waits on the frozen read.
|
||||
await server.close();
|
||||
await workerManagerPatch.dispose();
|
||||
});
|
||||
|
||||
// Self-contained (bare manager, no fire-and-forget resume), so it runs first: its
|
||||
// frozen syncs are fully drained by tearDown.
|
||||
testWidgets('a cancelled sync task does not clear the slot of the fresh task that superseded it', (tester) async {
|
||||
final manager = BackgroundSyncManager();
|
||||
|
||||
// First sync opens /sync/stream and is held open - the frozen suspended state.
|
||||
unawaited(manager.syncRemote());
|
||||
await server
|
||||
.streamOpenedNth(1)
|
||||
.timeout(const Duration(seconds: 30), onTimeout: () => fail('first sync isolate never opened /sync/stream'));
|
||||
|
||||
// Resume drops the stale task then immediately starts a fresh one, exactly as
|
||||
// _handleBetaTimelineResume does. cancelResumeSyncs cancels the first task; its
|
||||
// completion chain then fires and must NOT null the fresh task's slot.
|
||||
unawaited(manager.cancelResumeSyncs());
|
||||
unawaited(manager.syncRemote());
|
||||
await server
|
||||
.streamOpenedNth(2)
|
||||
.timeout(const Duration(seconds: 30), onTimeout: () => fail('fresh sync isolate never opened /sync/stream'));
|
||||
|
||||
// A third sync stream only opens if the cancelled task cleared the fresh slot,
|
||||
// letting the dedupe guard start a redundant sync.
|
||||
var thirdOpened = false;
|
||||
unawaited(server.streamOpenedNth(3).then((_) => thirdOpened = true));
|
||||
|
||||
// Let the cancelled task's completion chain settle, then ask to sync again.
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
unawaited(manager.syncRemote());
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
|
||||
expect(
|
||||
thirdOpened,
|
||||
isFalse,
|
||||
reason: 'the cancelled task cleared the fresh task slot, so a redundant third sync started (#28082 clobber)',
|
||||
);
|
||||
expect(server.streamOpenCount, 2);
|
||||
});
|
||||
|
||||
// The false-error-flash fix: a task cancelled by cancelResumeSyncs completes with a
|
||||
// CanceledError, which is not a sync failure and must not reach onRemoteSyncError.
|
||||
// Before the filter the stale task reported an error right after the fresh sync
|
||||
// started, so the status UI showed a failure for the whole healthy run.
|
||||
testWidgets('a cancelled sync does not report a false error to the status callbacks', (tester) async {
|
||||
final errors = <String>[];
|
||||
final manager = BackgroundSyncManager(onRemoteSyncError: errors.add);
|
||||
|
||||
// Hold the stream open so the task is genuinely in-flight when it is cancelled.
|
||||
unawaited(manager.syncRemote());
|
||||
await server
|
||||
.streamOpenedNth(1)
|
||||
.timeout(const Duration(seconds: 30), onTimeout: () => fail('sync isolate never opened /sync/stream'));
|
||||
|
||||
await manager.cancelResumeSyncs();
|
||||
// Let the cancelled task's completion chain run before checking the callbacks.
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
errors,
|
||||
isEmpty,
|
||||
reason: 'a cancelled task is not a real error; its CanceledError must not fire onRemoteSyncError',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ class FakeImmichServer {
|
|||
final (int, int, int) version;
|
||||
|
||||
final Completer<SyncStream> _streamOpened = Completer<SyncStream>();
|
||||
final List<SyncStream> _streamOpens = [];
|
||||
final Map<int, Completer<SyncStream>> _openWaiters = {};
|
||||
|
||||
int ackRequests = 0;
|
||||
|
||||
|
|
@ -18,6 +20,17 @@ class FakeImmichServer {
|
|||
/// Resolves when the sync isolate opens `POST /sync/stream`.
|
||||
Future<SyncStream> get streamOpened => _streamOpened.future;
|
||||
|
||||
/// How many `/sync/stream` requests have opened so far.
|
||||
int get streamOpenCount => _streamOpens.length;
|
||||
|
||||
/// Resolves when the [n]-th (1-indexed) `/sync/stream` opens.
|
||||
Future<SyncStream> streamOpenedNth(int n) {
|
||||
if (_streamOpens.length >= n) {
|
||||
return Future.value(_streamOpens[n - 1]);
|
||||
}
|
||||
return (_openWaiters[n] ??= Completer<SyncStream>()).future;
|
||||
}
|
||||
|
||||
static Future<FakeImmichServer> start({(int, int, int) version = (3, 0, 0)}) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final fake = FakeImmichServer._(server, version);
|
||||
|
|
@ -58,13 +71,17 @@ class FakeImmichServer {
|
|||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType('application', 'jsonlines+json')
|
||||
..contentLength = -1 // chunked: stays open to stream incrementally
|
||||
..contentLength =
|
||||
-1 // chunked: stays open to stream incrementally
|
||||
..bufferOutput = false;
|
||||
// Flush headers so the client's send() resolves and enters its read loop.
|
||||
await request.response.flush();
|
||||
final stream = SyncStream._(request.response);
|
||||
_streamOpens.add(stream);
|
||||
if (!_streamOpened.isCompleted) {
|
||||
_streamOpened.complete(SyncStream._(request.response));
|
||||
_streamOpened.complete(stream);
|
||||
}
|
||||
_openWaiters.remove(_streamOpens.length)?.complete(stream);
|
||||
}
|
||||
|
||||
Future<void> _respondJson(HttpRequest request, Object body) async {
|
||||
|
|
@ -83,8 +100,8 @@ class FakeImmichServer {
|
|||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_streamOpened.isCompleted) {
|
||||
await (await _streamOpened.future).close();
|
||||
for (final stream in _streamOpens) {
|
||||
await stream.close();
|
||||
}
|
||||
await _server.close(force: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,5 +1,6 @@
|
|||
import Accelerate
|
||||
import Flutter
|
||||
import ImageIO
|
||||
import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
|
|
@ -27,21 +28,21 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
||||
renderingIntent: .perceptual
|
||||
)!
|
||||
private static let decodeOptions = [
|
||||
private static let decodeOptions: [NSString: Bool] = [
|
||||
kCGImageSourceShouldCache: false,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true
|
||||
] as CFDictionary
|
||||
]
|
||||
|
||||
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, width: Int64?, height: Int64?, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
||||
|
||||
let request = RemoteImageRequest(id: requestId, completion: completion)
|
||||
|
||||
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
||||
Self.handleCompletion(request: request, encoded: preferEncoded, data: data, response: response, error: error)
|
||||
Self.handleCompletion(request: request, encoded: preferEncoded, width: width, height: height, data: data, response: response, error: error)
|
||||
}
|
||||
|
||||
request.task = task
|
||||
|
|
@ -49,7 +50,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||
task.resume()
|
||||
}
|
||||
|
||||
private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
|
||||
private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, width: Int64?, height: Int64?, data: Data?, response: URLResponse?, error: Error?) {
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
|
@ -87,8 +88,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
registry.remove(requestId: request.id)
|
||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||
}
|
||||
|
||||
var options: [NSString: Any] = decodeOptions
|
||||
if let maxPixelSize = targetThumbnailRenderSize(imageSource: imageSource, width: width, height: height) {
|
||||
options[kCGImageSourceThumbnailMaxPixelSize] = maxPixelSize
|
||||
}
|
||||
|
||||
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
|
||||
registry.remove(requestId: request.id)
|
||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||
}
|
||||
|
|
@ -120,6 +130,34 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns the longest rendered edge needed to cover the requested size.
|
||||
private static func targetThumbnailRenderSize(imageSource: CGImageSource, width: Int64?, height: Int64?) -> Int? {
|
||||
guard let width,
|
||||
let height,
|
||||
width > 0,
|
||||
height > 0,
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
|
||||
let pixelWidth = properties[kCGImagePropertyPixelWidth] as? NSNumber,
|
||||
let pixelHeight = properties[kCGImagePropertyPixelHeight] as? NSNumber else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let orientation = (properties[kCGImagePropertyOrientation] as? NSNumber)
|
||||
.flatMap { CGImagePropertyOrientation(rawValue: $0.uint32Value) } ?? .up
|
||||
let swapsDimensions: Bool
|
||||
switch orientation {
|
||||
case .leftMirrored, .right, .rightMirrored, .left:
|
||||
swapsDimensions = true
|
||||
default:
|
||||
swapsDimensions = false
|
||||
}
|
||||
let sourceWidth = swapsDimensions ? pixelHeight.doubleValue : pixelWidth.doubleValue
|
||||
let sourceHeight = swapsDimensions ? pixelWidth.doubleValue : pixelHeight.doubleValue
|
||||
let fillScale = max(Double(width) / sourceWidth, Double(height) / sourceHeight)
|
||||
let scale = min(1, fillScale)
|
||||
return scale < 1 ? Int(ceil(max(sourceWidth * scale, sourceHeight * scale))) : nil
|
||||
}
|
||||
|
||||
func cancelRequest(requestId: Int64) {
|
||||
Self.registry.remove(requestId: requestId)?.cancel()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
class CropAspectRatio {
|
||||
final int? numerator;
|
||||
final int? denominator;
|
||||
part 'aspect_ratios.freezed.dart';
|
||||
|
||||
final String? customLabel;
|
||||
final IconData? icon;
|
||||
@freezed
|
||||
abstract class CropAspectRatio with _$CropAspectRatio {
|
||||
const CropAspectRatio._();
|
||||
|
||||
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon});
|
||||
const factory CropAspectRatio({int? numerator, int? denominator, String? customLabel, IconData? icon}) =
|
||||
_CropAspectRatio;
|
||||
|
||||
static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
|
||||
static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
|
||||
|
|
@ -22,24 +23,6 @@ class CropAspectRatio {
|
|||
CropAspectRatio get flipped {
|
||||
return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other is CropAspectRatio &&
|
||||
other.numerator == numerator &&
|
||||
other.denominator == denominator &&
|
||||
other.customLabel == customLabel &&
|
||||
other.icon == icon;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
const aspectRatioFree = CropAspectRatio.free;
|
||||
|
|
|
|||
|
|
@ -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,51 +1,18 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'log.model.freezed.dart';
|
||||
|
||||
/// Log levels according to dart logging [Level]
|
||||
enum LogLevel { all, finest, finer, fine, config, info, warning, severe, shout, off }
|
||||
|
||||
class LogMessage {
|
||||
final String message;
|
||||
final LogLevel level;
|
||||
final DateTime createdAt;
|
||||
final String? logger;
|
||||
final String? error;
|
||||
final String? stack;
|
||||
|
||||
const LogMessage({
|
||||
required this.message,
|
||||
required this.level,
|
||||
required this.createdAt,
|
||||
this.logger,
|
||||
this.error,
|
||||
this.stack,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant LogMessage other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.message == message &&
|
||||
other.level == level &&
|
||||
other.createdAt == createdAt &&
|
||||
other.logger == logger &&
|
||||
other.error == error &&
|
||||
other.stack == stack;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return message.hashCode ^ level.hashCode ^ createdAt.hashCode ^ logger.hashCode ^ error.hashCode ^ stack.hashCode;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''LogMessage: {
|
||||
message: $message,
|
||||
level: $level,
|
||||
createdAt: $createdAt,
|
||||
logger: ${logger ?? '<NA>'},
|
||||
error: ${error ?? '<NA>'},
|
||||
stack: ${stack ?? '<NA>'},
|
||||
}''';
|
||||
}
|
||||
@freezed
|
||||
abstract class LogMessage with _$LogMessage {
|
||||
const factory LogMessage({
|
||||
required String message,
|
||||
required LogLevel level,
|
||||
required DateTime createdAt,
|
||||
String? logger,
|
||||
String? error,
|
||||
String? stack,
|
||||
}) = _LogMessage;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,9 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class Marker {
|
||||
final LatLng location;
|
||||
final String assetId;
|
||||
part 'map.model.freezed.dart';
|
||||
|
||||
const Marker({required this.location, required this.assetId});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Marker other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.location == location && other.assetId == assetId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => location.hashCode ^ assetId.hashCode;
|
||||
@freezed
|
||||
abstract class Marker with _$Marker {
|
||||
const factory Marker({required LatLng location, required String assetId}) = _Marker;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
part 'memory.model.freezed.dart';
|
||||
|
||||
// TODO(agg23): Remove enum suffix
|
||||
enum MemoryTypeEnum {
|
||||
// do not change this order!
|
||||
onThisDay,
|
||||
}
|
||||
|
||||
class MemoryData {
|
||||
final int year;
|
||||
@Freezed(fromJson: false, toJson: false)
|
||||
abstract class MemoryData with _$MemoryData {
|
||||
const MemoryData._();
|
||||
|
||||
const MemoryData({required this.year});
|
||||
|
||||
MemoryData copyWith({int? year}) {
|
||||
return MemoryData(year: year ?? this.year);
|
||||
}
|
||||
const factory MemoryData({required int year}) = _MemoryData;
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{'year': year};
|
||||
|
|
@ -29,144 +29,25 @@ class MemoryData {
|
|||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory MemoryData.fromJson(String source) => MemoryData.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'MemoryData(year: $year)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant MemoryData other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.year == year;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => year.hashCode;
|
||||
}
|
||||
|
||||
// Model for a memory stored in the server
|
||||
class DriftMemory {
|
||||
final String id;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? deletedAt;
|
||||
final String ownerId;
|
||||
|
||||
// enum
|
||||
final MemoryTypeEnum type;
|
||||
final MemoryData data;
|
||||
final bool isSaved;
|
||||
final DateTime memoryAt;
|
||||
final DateTime? seenAt;
|
||||
final DateTime? showAt;
|
||||
final DateTime? hideAt;
|
||||
final List<RemoteAsset> assets;
|
||||
|
||||
const DriftMemory({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.deletedAt,
|
||||
required this.ownerId,
|
||||
required this.type,
|
||||
required this.data,
|
||||
required this.isSaved,
|
||||
required this.memoryAt,
|
||||
this.seenAt,
|
||||
this.showAt,
|
||||
this.hideAt,
|
||||
required this.assets,
|
||||
});
|
||||
|
||||
DriftMemory copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
// TODO(agg23): DriftMemoryRepository currently mutates `assets`
|
||||
@Freezed(makeCollectionsUnmodifiable: false)
|
||||
abstract class DriftMemory with _$DriftMemory {
|
||||
const factory DriftMemory({
|
||||
required String id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
String? ownerId,
|
||||
MemoryTypeEnum? type,
|
||||
MemoryData? data,
|
||||
bool? isSaved,
|
||||
DateTime? memoryAt,
|
||||
required String ownerId,
|
||||
required MemoryTypeEnum type,
|
||||
required MemoryData data,
|
||||
required bool isSaved,
|
||||
required DateTime memoryAt,
|
||||
DateTime? seenAt,
|
||||
DateTime? showAt,
|
||||
DateTime? hideAt,
|
||||
List<RemoteAsset>? assets,
|
||||
}) {
|
||||
return DriftMemory(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
type: type ?? this.type,
|
||||
data: data ?? this.data,
|
||||
isSaved: isSaved ?? this.isSaved,
|
||||
memoryAt: memoryAt ?? this.memoryAt,
|
||||
seenAt: seenAt ?? this.seenAt,
|
||||
showAt: showAt ?? this.showAt,
|
||||
hideAt: hideAt ?? this.hideAt,
|
||||
assets: assets ?? this.assets,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Memory {
|
||||
id: $id,
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
deletedAt: ${deletedAt ?? "<NA>"},
|
||||
ownerId: $ownerId,
|
||||
type: $type,
|
||||
data: $data,
|
||||
isSaved: $isSaved,
|
||||
memoryAt: $memoryAt,
|
||||
seenAt: ${seenAt ?? "<NA>"},
|
||||
showAt: ${showAt ?? "<NA>"},
|
||||
hideAt: ${hideAt ?? "<NA>"},
|
||||
assets: $assets
|
||||
}''';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant DriftMemory other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return other.id == id &&
|
||||
other.createdAt == createdAt &&
|
||||
other.updatedAt == updatedAt &&
|
||||
other.deletedAt == deletedAt &&
|
||||
other.ownerId == ownerId &&
|
||||
other.type == type &&
|
||||
other.data == data &&
|
||||
other.isSaved == isSaved &&
|
||||
other.memoryAt == memoryAt &&
|
||||
other.seenAt == seenAt &&
|
||||
other.showAt == showAt &&
|
||||
other.hideAt == hideAt &&
|
||||
listEquals(other.assets, assets);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
deletedAt.hashCode ^
|
||||
ownerId.hashCode ^
|
||||
type.hashCode ^
|
||||
data.hashCode ^
|
||||
isSaved.hashCode ^
|
||||
memoryAt.hashCode ^
|
||||
seenAt.hashCode ^
|
||||
showAt.hashCode ^
|
||||
hideAt.hashCode ^
|
||||
assets.hashCode;
|
||||
}
|
||||
required List<RemoteAsset> assets,
|
||||
}) = _DriftMemory;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +1,9 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'person.model.freezed.dart';
|
||||
|
||||
// TODO: Remove PersonDto once Isar is removed
|
||||
class PersonDto {
|
||||
const PersonDto({
|
||||
required this.id,
|
||||
this.birthDate,
|
||||
required this.isHidden,
|
||||
required this.name,
|
||||
required this.thumbnailPath,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final DateTime? birthDate;
|
||||
final bool isHidden;
|
||||
final String name;
|
||||
final String thumbnailPath;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)';
|
||||
}
|
||||
|
||||
PersonDto copyWith({
|
||||
String? id,
|
||||
DateTime? birthDate,
|
||||
bool? isHidden,
|
||||
String? name,
|
||||
String? thumbnailPath,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return PersonDto(
|
||||
id: id ?? this.id,
|
||||
birthDate: birthDate ?? this.birthDate,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
name: name ?? this.name,
|
||||
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'birthDate': birthDate?.millisecondsSinceEpoch,
|
||||
'isHidden': isHidden,
|
||||
'name': name,
|
||||
'thumbnailPath': thumbnailPath,
|
||||
'updatedAt': updatedAt?.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
factory PersonDto.fromMap(Map<String, dynamic> map) {
|
||||
return PersonDto(
|
||||
id: map['id'] as String,
|
||||
birthDate: map['birthDate'] != null ? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int) : null,
|
||||
isHidden: map['isHidden'] as bool,
|
||||
name: map['name'] as String,
|
||||
thumbnailPath: map['thumbnailPath'] as String,
|
||||
updatedAt: map['updatedAt'] != null ? DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int) : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory PersonDto.fromJson(String source) => PersonDto.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
bool operator ==(covariant PersonDto other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.id == id &&
|
||||
other.birthDate == birthDate &&
|
||||
other.isHidden == isHidden &&
|
||||
other.name == name &&
|
||||
other.thumbnailPath == thumbnailPath &&
|
||||
other.updatedAt == updatedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
birthDate.hashCode ^
|
||||
isHidden.hashCode ^
|
||||
name.hashCode ^
|
||||
thumbnailPath.hashCode ^
|
||||
updatedAt.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class SearchResult {
|
||||
final List<BaseAsset> assets;
|
||||
final int? nextPage;
|
||||
part 'search_result.model.freezed.dart';
|
||||
|
||||
const SearchResult({required this.assets, this.nextPage});
|
||||
@Freezed(toStringOverride: false)
|
||||
abstract class SearchResult with _$SearchResult {
|
||||
const SearchResult._();
|
||||
|
||||
const factory SearchResult({required List<BaseAsset> assets, int? nextPage}) = _SearchResult;
|
||||
|
||||
// Explicitly don't log results, only attributes
|
||||
@override
|
||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchResult other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.assets, assets) && other.nextPage == nextPage;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,11 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class Tag {
|
||||
final String id;
|
||||
final String value;
|
||||
part 'tag.model.freezed.dart';
|
||||
|
||||
const Tag({required this.id, required this.value});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Tag(id: $id, value: $value)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Tag other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.id == id && other.value == value;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ value.hashCode;
|
||||
}
|
||||
@freezed
|
||||
abstract class Tag with _$Tag {
|
||||
const factory Tag({required String id, required String value}) = _Tag;
|
||||
|
||||
static Tag fromDto(TagResponseDto dto) {
|
||||
return Tag(id: dto.id, value: dto.value);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'user.model.freezed.dart';
|
||||
|
||||
enum AvatarColor {
|
||||
// do not change this order or reuse indices for other purposes, adding is OK
|
||||
primary("primary"),
|
||||
|
|
@ -164,78 +167,15 @@ profileChangedAt: $profileChangedAt
|
|||
quotaUsageInBytes.hashCode;
|
||||
}
|
||||
|
||||
class PartnerUserDto {
|
||||
final String id;
|
||||
final String email;
|
||||
final String name;
|
||||
final bool inTimeline;
|
||||
|
||||
final String? profileImagePath;
|
||||
|
||||
const PartnerUserDto({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.inTimeline,
|
||||
this.profileImagePath,
|
||||
});
|
||||
|
||||
PartnerUserDto copyWith({String? id, String? email, String? name, bool? inTimeline, String? profileImagePath}) {
|
||||
return PartnerUserDto(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
name: name ?? this.name,
|
||||
inTimeline: inTimeline ?? this.inTimeline,
|
||||
profileImagePath: profileImagePath ?? this.profileImagePath,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'email': email,
|
||||
'name': name,
|
||||
'inTimeline': inTimeline,
|
||||
'profileImagePath': profileImagePath,
|
||||
};
|
||||
}
|
||||
|
||||
factory PartnerUserDto.fromMap(Map<String, dynamic> map) {
|
||||
return PartnerUserDto(
|
||||
id: map['id'] as String,
|
||||
email: map['email'] as String,
|
||||
name: map['name'] as String,
|
||||
inTimeline: map['inTimeline'] as bool,
|
||||
profileImagePath: map['profileImagePath'] != null ? map['profileImagePath'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory PartnerUserDto.fromJson(String source) => PartnerUserDto.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PartnerUserDto(id: $id, email: $email, name: $name, inTimeline: $inTimeline, profileImagePath: $profileImagePath)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant PartnerUserDto other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.id == id &&
|
||||
other.email == email &&
|
||||
other.name == name &&
|
||||
other.inTimeline == inTimeline &&
|
||||
other.profileImagePath == profileImagePath;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode;
|
||||
}
|
||||
@freezed
|
||||
abstract class PartnerUserDto with _$PartnerUserDto {
|
||||
const factory PartnerUserDto({
|
||||
required String id,
|
||||
required String email,
|
||||
required String name,
|
||||
required bool inTimeline,
|
||||
String? profileImagePath,
|
||||
}) = _PartnerUserDto;
|
||||
}
|
||||
|
||||
class User {
|
||||
|
|
|
|||
|
|
@ -11,105 +11,32 @@ enum UserMetadataKey {
|
|||
license,
|
||||
}
|
||||
|
||||
class Onboarding {
|
||||
final bool isOnboarded;
|
||||
@freezed
|
||||
abstract class Onboarding with _$Onboarding {
|
||||
const Onboarding._();
|
||||
|
||||
const Onboarding({required this.isOnboarded});
|
||||
|
||||
Onboarding copyWith({bool? isOnboarded}) {
|
||||
return Onboarding(isOnboarded: isOnboarded ?? this.isOnboarded);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
final onboarding = <String, Object?>{};
|
||||
onboarding["isOnboarded"] = isOnboarded;
|
||||
return onboarding;
|
||||
}
|
||||
const factory Onboarding({required bool isOnboarded}) = _Onboarding;
|
||||
|
||||
factory Onboarding.fromMap(Map<String, Object?> map) {
|
||||
return Onboarding(isOnboarded: map["isOnboarded"]! as bool);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Onboarding {
|
||||
isOnboarded: $isOnboarded,
|
||||
}''';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Onboarding other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isOnboarded == other.isOnboarded;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isOnboarded.hashCode;
|
||||
}
|
||||
|
||||
class Preferences {
|
||||
final bool foldersEnabled;
|
||||
final bool memoriesEnabled;
|
||||
final bool peopleEnabled;
|
||||
final bool ratingsEnabled;
|
||||
final bool sharedLinksEnabled;
|
||||
final bool tagsEnabled;
|
||||
final AvatarColor userAvatarColor;
|
||||
final bool showSupportBadge;
|
||||
final int minimumFaces;
|
||||
@freezed
|
||||
abstract class Preferences with _$Preferences {
|
||||
const Preferences._();
|
||||
|
||||
const Preferences({
|
||||
this.foldersEnabled = false,
|
||||
this.memoriesEnabled = true,
|
||||
this.peopleEnabled = true,
|
||||
this.ratingsEnabled = false,
|
||||
this.sharedLinksEnabled = true,
|
||||
this.tagsEnabled = false,
|
||||
this.userAvatarColor = AvatarColor.primary,
|
||||
this.showSupportBadge = true,
|
||||
this.minimumFaces = 3,
|
||||
});
|
||||
|
||||
Preferences copyWith({
|
||||
bool? foldersEnabled,
|
||||
bool? memoriesEnabled,
|
||||
bool? peopleEnabled,
|
||||
bool? ratingsEnabled,
|
||||
bool? sharedLinksEnabled,
|
||||
bool? tagsEnabled,
|
||||
AvatarColor? userAvatarColor,
|
||||
bool? showSupportBadge,
|
||||
int? minimumFaces,
|
||||
}) {
|
||||
return Preferences(
|
||||
foldersEnabled: foldersEnabled ?? this.foldersEnabled,
|
||||
memoriesEnabled: memoriesEnabled ?? this.memoriesEnabled,
|
||||
peopleEnabled: peopleEnabled ?? this.peopleEnabled,
|
||||
ratingsEnabled: ratingsEnabled ?? this.ratingsEnabled,
|
||||
sharedLinksEnabled: sharedLinksEnabled ?? this.sharedLinksEnabled,
|
||||
tagsEnabled: tagsEnabled ?? this.tagsEnabled,
|
||||
userAvatarColor: userAvatarColor ?? this.userAvatarColor,
|
||||
showSupportBadge: showSupportBadge ?? this.showSupportBadge,
|
||||
minimumFaces: minimumFaces ?? this.minimumFaces,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
final preferences = <String, Object?>{};
|
||||
preferences["folders-Enabled"] = foldersEnabled;
|
||||
preferences["memories-Enabled"] = memoriesEnabled;
|
||||
preferences["people-Enabled"] = peopleEnabled;
|
||||
preferences["ratings-Enabled"] = ratingsEnabled;
|
||||
preferences["sharedLinks-Enabled"] = sharedLinksEnabled;
|
||||
preferences["tags-Enabled"] = tagsEnabled;
|
||||
preferences["avatar-Color"] = userAvatarColor.value;
|
||||
preferences["purchase-ShowSupportBadge"] = showSupportBadge;
|
||||
preferences["minimumFaces"] = minimumFaces;
|
||||
return preferences;
|
||||
}
|
||||
const factory Preferences({
|
||||
@Default(false) bool foldersEnabled,
|
||||
@Default(true) bool memoriesEnabled,
|
||||
@Default(true) bool peopleEnabled,
|
||||
@Default(false) bool ratingsEnabled,
|
||||
@Default(true) bool sharedLinksEnabled,
|
||||
@Default(false) bool tagsEnabled,
|
||||
@Default(AvatarColor.primary) AvatarColor userAvatarColor,
|
||||
@Default(true) bool showSupportBadge,
|
||||
@Default(3) int minimumFaces,
|
||||
}) = _Preferences;
|
||||
|
||||
factory Preferences.fromMap(Map<String, Object?> map) {
|
||||
return Preferences(
|
||||
|
|
@ -127,75 +54,14 @@ class Preferences {
|
|||
minimumFaces: (map["people"] as Map<String, Object?>?)?["minimumFaces"] as int? ?? 3,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Preferences: {
|
||||
foldersEnabled: $foldersEnabled,
|
||||
memoriesEnabled: $memoriesEnabled,
|
||||
peopleEnabled: $peopleEnabled,
|
||||
ratingsEnabled: $ratingsEnabled,
|
||||
sharedLinksEnabled: $sharedLinksEnabled,
|
||||
tagsEnabled: $tagsEnabled,
|
||||
userAvatarColor: $userAvatarColor,
|
||||
showSupportBadge: $showSupportBadge,
|
||||
minimumFaces: $minimumFaces,
|
||||
}''';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Preferences other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.foldersEnabled == foldersEnabled &&
|
||||
other.memoriesEnabled == memoriesEnabled &&
|
||||
other.peopleEnabled == peopleEnabled &&
|
||||
other.ratingsEnabled == ratingsEnabled &&
|
||||
other.sharedLinksEnabled == sharedLinksEnabled &&
|
||||
other.tagsEnabled == tagsEnabled &&
|
||||
other.userAvatarColor == userAvatarColor &&
|
||||
other.showSupportBadge == showSupportBadge &&
|
||||
other.minimumFaces == minimumFaces;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return foldersEnabled.hashCode ^
|
||||
memoriesEnabled.hashCode ^
|
||||
peopleEnabled.hashCode ^
|
||||
ratingsEnabled.hashCode ^
|
||||
sharedLinksEnabled.hashCode ^
|
||||
tagsEnabled.hashCode ^
|
||||
userAvatarColor.hashCode ^
|
||||
showSupportBadge.hashCode ^
|
||||
minimumFaces.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
class License {
|
||||
final DateTime activatedAt;
|
||||
final String activationKey;
|
||||
final String licenseKey;
|
||||
@freezed
|
||||
abstract class License with _$License {
|
||||
const License._();
|
||||
|
||||
const License({required this.activatedAt, required this.activationKey, required this.licenseKey});
|
||||
|
||||
License copyWith({DateTime? activatedAt, String? activationKey, String? licenseKey}) {
|
||||
return License(
|
||||
activatedAt: activatedAt ?? this.activatedAt,
|
||||
activationKey: activationKey ?? this.activationKey,
|
||||
licenseKey: licenseKey ?? this.licenseKey,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
final license = <String, Object?>{};
|
||||
license["activatedAt"] = activatedAt;
|
||||
license["activationKey"] = activationKey;
|
||||
license["licenseKey"] = licenseKey;
|
||||
return license;
|
||||
}
|
||||
const factory License({required DateTime activatedAt, required String activationKey, required String licenseKey}) =
|
||||
_License;
|
||||
|
||||
factory License.fromMap(Map<String, Object?> map) {
|
||||
return License(
|
||||
|
|
@ -204,27 +70,6 @@ class License {
|
|||
licenseKey: map["licenseKey"]! as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''License {
|
||||
activatedAt: $activatedAt,
|
||||
activationKey: $activationKey,
|
||||
licenseKey: $licenseKey,
|
||||
}''';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant License other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return activatedAt == other.activatedAt && activationKey == other.activationKey && licenseKey == other.licenseKey;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => activatedAt.hashCode ^ activationKey.hashCode ^ licenseKey.hashCode;
|
||||
}
|
||||
|
||||
// Model for a user metadata stored in the server
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,16 +50,39 @@ class BackgroundSyncManager {
|
|||
this.onCloudIdSyncError,
|
||||
});
|
||||
|
||||
// The tasks the app-resume path re-runs. One in-flight when the app was suspended
|
||||
// stays referenced but frozen, so on resume the dedupe guards would hand back the
|
||||
// stale task instead of syncing (#28082). Websocket and cloud-id are excluded - the
|
||||
// resume path never restarts them. [_allTasks] builds on this so the lists can't drift.
|
||||
List<Cancelable?> get _resumeSyncTasks => [_syncTask, _deviceAlbumSyncTask, _hashTask, _linkedAlbumSyncTask];
|
||||
|
||||
List<Cancelable?> get _allTasks => [_syncWebsocketTask, _cloudIdSyncTask, ..._resumeSyncTasks];
|
||||
|
||||
Future<void> cancel() async {
|
||||
_syncQueued = false;
|
||||
final tasks = [
|
||||
_syncTask,
|
||||
_syncWebsocketTask,
|
||||
_cloudIdSyncTask,
|
||||
_linkedAlbumSyncTask,
|
||||
_deviceAlbumSyncTask,
|
||||
_hashTask,
|
||||
];
|
||||
final tasks = _allTasks;
|
||||
_syncTask = null;
|
||||
_syncWebsocketTask = null;
|
||||
_cloudIdSyncTask = null;
|
||||
_linkedAlbumSyncTask = null;
|
||||
_deviceAlbumSyncTask = null;
|
||||
_hashTask = null;
|
||||
await _cancelAll(tasks);
|
||||
}
|
||||
|
||||
Future<void> cancelResumeSyncs() async {
|
||||
_syncQueued = false;
|
||||
final tasks = _resumeSyncTasks;
|
||||
_syncTask = null;
|
||||
_deviceAlbumSyncTask = null;
|
||||
_hashTask = null;
|
||||
_linkedAlbumSyncTask = null;
|
||||
await _cancelAll(tasks);
|
||||
}
|
||||
|
||||
// Cancels every task in [tasks] and waits for them to unwind. Callers null out
|
||||
// their own fields first, so the sync guards see a clean slate immediately.
|
||||
Future<void> _cancelAll(List<Cancelable?> tasks) async {
|
||||
final futures = [
|
||||
for (final task in tasks)
|
||||
if (task != null) task.future,
|
||||
|
|
@ -67,13 +90,6 @@ class BackgroundSyncManager {
|
|||
for (final task in tasks) {
|
||||
task?.cancel();
|
||||
}
|
||||
_syncTask = null;
|
||||
_syncWebsocketTask = null;
|
||||
_cloudIdSyncTask = null;
|
||||
_linkedAlbumSyncTask = null;
|
||||
_deviceAlbumSyncTask = null;
|
||||
_hashTask = null;
|
||||
|
||||
try {
|
||||
await Future.wait(futures);
|
||||
} on CanceledError {
|
||||
|
|
@ -84,14 +100,14 @@ class BackgroundSyncManager {
|
|||
// No need to cancel the task, as it can also be run when the user logs out
|
||||
Future<void> syncLocal({bool full = false}) {
|
||||
if (_deviceAlbumSyncTask != null) {
|
||||
return _deviceAlbumSyncTask!.future;
|
||||
return _deviceAlbumSyncTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
|
||||
}
|
||||
|
||||
onLocalSyncStart?.call();
|
||||
|
||||
// We use a ternary operator to avoid [_deviceAlbumSyncTask] from being
|
||||
// captured by the closure passed to [runInIsolateGentle].
|
||||
_deviceAlbumSyncTask = full
|
||||
final task = _deviceAlbumSyncTask = full
|
||||
? runInIsolateGentle(
|
||||
computation: (ref) => ref.read(localSyncServiceProvider).sync(full: true),
|
||||
debugLabel: 'local-sync-full-true',
|
||||
|
|
@ -101,37 +117,43 @@ class BackgroundSyncManager {
|
|||
debugLabel: 'local-sync-full-false',
|
||||
);
|
||||
|
||||
return _deviceAlbumSyncTask!
|
||||
return task
|
||||
.whenComplete(() {
|
||||
_deviceAlbumSyncTask = null;
|
||||
if (identical(_deviceAlbumSyncTask, task)) {
|
||||
_deviceAlbumSyncTask = null;
|
||||
}
|
||||
onLocalSyncComplete?.call();
|
||||
})
|
||||
.catchError((error) {
|
||||
onLocalSyncError?.call(error.toString());
|
||||
_deviceAlbumSyncTask = null;
|
||||
if (error is! CanceledError) {
|
||||
onLocalSyncError?.call(error.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> hashAssets() {
|
||||
if (_hashTask != null) {
|
||||
return _hashTask!.future;
|
||||
return _hashTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
|
||||
}
|
||||
|
||||
onHashingStart?.call();
|
||||
|
||||
_hashTask = runInIsolateGentle(
|
||||
final task = _hashTask = runInIsolateGentle(
|
||||
computation: (ref) => ref.read(hashServiceProvider).hashAssets(),
|
||||
debugLabel: 'hash-assets',
|
||||
);
|
||||
|
||||
return _hashTask!
|
||||
return task
|
||||
.whenComplete(() {
|
||||
onHashingComplete?.call();
|
||||
_hashTask = null;
|
||||
if (identical(_hashTask, task)) {
|
||||
_hashTask = null;
|
||||
}
|
||||
})
|
||||
.catchError((error) {
|
||||
onHashingError?.call(error.toString());
|
||||
_hashTask = null;
|
||||
if (error is! CanceledError) {
|
||||
onHashingError?.call(error.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -143,11 +165,11 @@ class BackgroundSyncManager {
|
|||
|
||||
onRemoteSyncStart?.call();
|
||||
|
||||
_syncTask = runInIsolateGentle(
|
||||
final task = _syncTask = runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
|
||||
debugLabel: 'remote-sync',
|
||||
);
|
||||
return _syncTask!
|
||||
return task
|
||||
.then((result) {
|
||||
final success = result ?? false;
|
||||
onRemoteSyncComplete?.call(success);
|
||||
|
|
@ -155,15 +177,21 @@ class BackgroundSyncManager {
|
|||
return success;
|
||||
})
|
||||
.catchError((error) {
|
||||
onRemoteSyncError?.call(error.toString());
|
||||
if (error is! CanceledError) {
|
||||
onRemoteSyncError?.call(error.toString());
|
||||
}
|
||||
_syncQueued = false;
|
||||
return false;
|
||||
})
|
||||
// A task clears only its own slot: one that was cancelled and superseded by a
|
||||
// fresh task (see cancelResumeSyncs) must not null the new task's slot.
|
||||
.whenComplete(() {
|
||||
_syncTask = null;
|
||||
if (_syncQueued) {
|
||||
_syncQueued = false;
|
||||
unawaited(syncRemote());
|
||||
if (identical(_syncTask, task)) {
|
||||
_syncTask = null;
|
||||
if (_syncQueued) {
|
||||
_syncQueued = false;
|
||||
unawaited(syncRemote());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -210,13 +238,21 @@ class BackgroundSyncManager {
|
|||
|
||||
Future<void> syncLinkedAlbum() {
|
||||
if (_linkedAlbumSyncTask != null) {
|
||||
return _linkedAlbumSyncTask!.future;
|
||||
return _linkedAlbumSyncTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
|
||||
}
|
||||
|
||||
_linkedAlbumSyncTask = runInIsolateGentle(computation: syncLinkedAlbumsIsolated, debugLabel: 'linked-album-sync');
|
||||
return _linkedAlbumSyncTask!.whenComplete(() {
|
||||
_linkedAlbumSyncTask = null;
|
||||
});
|
||||
final task = _linkedAlbumSyncTask = runInIsolateGentle(
|
||||
computation: syncLinkedAlbumsIsolated,
|
||||
debugLabel: 'linked-album-sync',
|
||||
);
|
||||
return task
|
||||
.whenComplete(() {
|
||||
if (identical(_linkedAlbumSyncTask, task)) {
|
||||
_linkedAlbumSyncTask = null;
|
||||
}
|
||||
})
|
||||
// a cancelled resume sync is not a failure; absorb it so the websocket callers don't get an uncaught error
|
||||
.catchError((_) {}, test: (error) => error is CanceledError);
|
||||
}
|
||||
|
||||
Future<void> syncCloudIds() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
|
@ -36,7 +37,11 @@ abstract class ImageRequest {
|
|||
|
||||
void _onCancelled();
|
||||
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(
|
||||
int address,
|
||||
int length, {
|
||||
ui.Size? decodeSize,
|
||||
}) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
|
|
@ -62,7 +67,8 @@ abstract class ImageRequest {
|
|||
return null;
|
||||
}
|
||||
|
||||
final codec = await descriptor.instantiateCodec();
|
||||
final target = _targetSize(descriptor.width, descriptor.height, decodeSize);
|
||||
final codec = await descriptor.instantiateCodec(targetWidth: target?.$1, targetHeight: target?.$2);
|
||||
if (_isCancelled) {
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
|
|
@ -72,8 +78,8 @@ abstract class ImageRequest {
|
|||
return (codec, descriptor);
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
||||
final result = await _codecFromEncodedPlatformImage(address, length);
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async {
|
||||
final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize);
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -96,6 +102,19 @@ abstract class ImageRequest {
|
|||
return frame;
|
||||
}
|
||||
|
||||
(int, int)? _targetSize(int width, int height, ui.Size? decodeSize) {
|
||||
if (width <= 0 || height <= 0 || decodeSize == null || decodeSize.width <= 0 || decodeSize.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final scale = math.max(decodeSize.width / width, decodeSize.height / height);
|
||||
if (scale >= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((width * scale).ceil(), (height * scale).ceil());
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ part of 'image_request.dart';
|
|||
class RemoteImageRequest extends ImageRequest {
|
||||
final String uri;
|
||||
|
||||
RemoteImageRequest({required this.uri});
|
||||
/// Physical size to decode, or null for the source size.
|
||||
final ui.Size? decodeSize;
|
||||
|
||||
RemoteImageRequest({required this.uri, this.decodeSize});
|
||||
|
||||
@override
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||
|
|
@ -11,10 +14,20 @@ class RemoteImageRequest extends ImageRequest {
|
|||
return null;
|
||||
}
|
||||
|
||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
||||
final info = await remoteImageApi.requestImage(
|
||||
uri,
|
||||
requestId: requestId,
|
||||
preferEncoded: false,
|
||||
width: decodeSize?.width.ceil(),
|
||||
height: decodeSize?.height.ceil(),
|
||||
);
|
||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(
|
||||
pointer,
|
||||
length,
|
||||
decodeSize: decodeSize,
|
||||
),
|
||||
{
|
||||
'pointer': final int pointer,
|
||||
'width': final int width,
|
||||
|
|
@ -33,7 +46,13 @@ class RemoteImageRequest extends ImageRequest {
|
|||
return null;
|
||||
}
|
||||
|
||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
|
||||
final info = await remoteImageApi.requestImage(
|
||||
uri,
|
||||
requestId: requestId,
|
||||
preferEncoded: true,
|
||||
width: null,
|
||||
height: null,
|
||||
);
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,34 +1,14 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
class AuxilaryEndpoint {
|
||||
final String url;
|
||||
final AuxCheckStatus status;
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
const AuxilaryEndpoint({required this.url, required this.status});
|
||||
part 'auxilary_endpoint.model.freezed.dart';
|
||||
|
||||
AuxilaryEndpoint copyWith({String? url, AuxCheckStatus? status}) {
|
||||
return AuxilaryEndpoint(url: url ?? this.url, status: status ?? this.status);
|
||||
}
|
||||
@Freezed(fromJson: false, toJson: false)
|
||||
abstract class AuxilaryEndpoint with _$AuxilaryEndpoint {
|
||||
const AuxilaryEndpoint._();
|
||||
|
||||
@override
|
||||
String toString() => 'AuxilaryEndpoint(url: $url, status: $status)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant AuxilaryEndpoint other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.url == url && other.status == status;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => url.hashCode ^ status.hashCode;
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{'url': url, 'status': status.toMap()};
|
||||
}
|
||||
const factory AuxilaryEndpoint({required String url, required AuxCheckStatus status}) = _AuxilaryEndpoint;
|
||||
|
||||
factory AuxilaryEndpoint.fromMap(Map<String, dynamic> map) {
|
||||
return AuxilaryEndpoint(
|
||||
|
|
@ -37,50 +17,23 @@ class AuxilaryEndpoint {
|
|||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory AuxilaryEndpoint.fromJson(String source) =>
|
||||
AuxilaryEndpoint.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
class AuxCheckStatus {
|
||||
final String name;
|
||||
const AuxCheckStatus({required this.name});
|
||||
const AuxCheckStatus._(this.name);
|
||||
// TODO(agg23): Should be an enum
|
||||
@freezed
|
||||
abstract class AuxCheckStatus with _$AuxCheckStatus {
|
||||
const AuxCheckStatus._();
|
||||
|
||||
static const loading = AuxCheckStatus._('loading');
|
||||
static const valid = AuxCheckStatus._('valid');
|
||||
static const error = AuxCheckStatus._('error');
|
||||
static const unknown = AuxCheckStatus._('unknown');
|
||||
const factory AuxCheckStatus({required String name}) = _AuxCheckStatus;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant AuxCheckStatus other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.name == name;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => name.hashCode;
|
||||
|
||||
AuxCheckStatus copyWith({String? name}) {
|
||||
return AuxCheckStatus(name: name ?? this.name);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{'name': name};
|
||||
}
|
||||
static const loading = AuxCheckStatus(name: 'loading');
|
||||
static const valid = AuxCheckStatus(name: 'valid');
|
||||
static const error = AuxCheckStatus(name: 'error');
|
||||
static const unknown = AuxCheckStatus(name: 'unknown');
|
||||
|
||||
factory AuxCheckStatus.fromMap(Map<String, dynamic> map) {
|
||||
return AuxCheckStatus(name: map['name'] as String);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory AuxCheckStatus.fromJson(String source) => AuxCheckStatus.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'AuxCheckStatus(name: $name)';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,10 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
class BiometricStatus {
|
||||
final List<BiometricType> availableBiometrics;
|
||||
final bool canAuthenticate;
|
||||
part 'biometric_status.model.freezed.dart';
|
||||
|
||||
const BiometricStatus({required this.availableBiometrics, required this.canAuthenticate});
|
||||
|
||||
@override
|
||||
String toString() => 'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)';
|
||||
|
||||
BiometricStatus copyWith({List<BiometricType>? availableBiometrics, bool? canAuthenticate}) {
|
||||
return BiometricStatus(
|
||||
availableBiometrics: availableBiometrics ?? this.availableBiometrics,
|
||||
canAuthenticate: canAuthenticate ?? this.canAuthenticate,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant BiometricStatus other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.availableBiometrics, availableBiometrics) && other.canAuthenticate == canAuthenticate;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => availableBiometrics.hashCode ^ canAuthenticate.hashCode;
|
||||
@freezed
|
||||
abstract class BiometricStatus with _$BiometricStatus {
|
||||
const factory BiometricStatus({required List<BiometricType> availableBiometrics, required bool canAuthenticate}) =
|
||||
_BiometricStatus;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,18 @@
|
|||
import 'dart:convert';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'cast_manager_state.freezed.dart';
|
||||
|
||||
enum CastDestinationType { googleCast }
|
||||
|
||||
enum CastState { idle, playing, paused, buffering }
|
||||
|
||||
class CastManagerState {
|
||||
final bool isCasting;
|
||||
final String receiverName;
|
||||
final CastState castState;
|
||||
final Duration currentTime;
|
||||
final Duration duration;
|
||||
|
||||
const CastManagerState({
|
||||
required this.isCasting,
|
||||
required this.receiverName,
|
||||
required this.castState,
|
||||
required this.currentTime,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
CastManagerState copyWith({
|
||||
bool? isCasting,
|
||||
String? receiverName,
|
||||
CastState? castState,
|
||||
Duration? currentTime,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return CastManagerState(
|
||||
isCasting: isCasting ?? this.isCasting,
|
||||
receiverName: receiverName ?? this.receiverName,
|
||||
castState: castState ?? this.castState,
|
||||
currentTime: currentTime ?? this.currentTime,
|
||||
duration: duration ?? this.duration,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'isCasting': isCasting});
|
||||
result.addAll({'receiverName': receiverName});
|
||||
result.addAll({'castState': castState});
|
||||
result.addAll({'currentTime': currentTime.inSeconds});
|
||||
result.addAll({'duration': duration.inSeconds});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory CastManagerState.fromMap(Map<String, dynamic> map) {
|
||||
return CastManagerState(
|
||||
isCasting: map['isCasting'] ?? false,
|
||||
receiverName: map['receiverName'] ?? '',
|
||||
castState: map['castState'] ?? CastState.idle,
|
||||
currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0),
|
||||
duration: Duration(seconds: map['duration']?.toInt() ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CastManagerState.fromJson(String source) => CastManagerState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CastManagerState(isCasting: $isCasting, receiverName: $receiverName, castState: $castState, currentTime: $currentTime, duration: $duration)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other is CastManagerState &&
|
||||
other.isCasting == isCasting &&
|
||||
other.receiverName == receiverName &&
|
||||
other.castState == castState &&
|
||||
other.currentTime == currentTime &&
|
||||
other.duration == duration;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
isCasting.hashCode ^ receiverName.hashCode ^ castState.hashCode ^ currentTime.hashCode ^ duration.hashCode;
|
||||
@freezed
|
||||
abstract class CastManagerState with _$CastManagerState {
|
||||
const factory CastManagerState({
|
||||
required bool isCasting,
|
||||
required String receiverName,
|
||||
required CastState castState,
|
||||
required Duration currentTime,
|
||||
required Duration duration,
|
||||
}) = _CastManagerState;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,88 +1,20 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
class DownloadInfo {
|
||||
final String fileName;
|
||||
final double progress;
|
||||
// enum
|
||||
final TaskStatus status;
|
||||
|
||||
const DownloadInfo({required this.fileName, required this.progress, required this.status});
|
||||
|
||||
DownloadInfo copyWith({String? fileName, double? progress, TaskStatus? status}) {
|
||||
return DownloadInfo(
|
||||
fileName: fileName ?? this.fileName,
|
||||
progress: progress ?? this.progress,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{'fileName': fileName, 'progress': progress, 'status': status.index};
|
||||
}
|
||||
|
||||
factory DownloadInfo.fromMap(Map<String, dynamic> map) {
|
||||
return DownloadInfo(
|
||||
fileName: map['fileName'] as String,
|
||||
progress: map['progress'] as double,
|
||||
status: TaskStatus.values[map['status'] as int],
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory DownloadInfo.fromJson(String source) => DownloadInfo.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant DownloadInfo other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.fileName == fileName && other.progress == progress && other.status == status;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode;
|
||||
}
|
||||
|
||||
class DownloadState {
|
||||
// enum
|
||||
final TaskStatus downloadStatus;
|
||||
final Map<String, DownloadInfo> taskProgress;
|
||||
final bool showProgress;
|
||||
const DownloadState({required this.downloadStatus, required this.taskProgress, required this.showProgress});
|
||||
|
||||
DownloadState copyWith({TaskStatus? downloadStatus, Map<String, DownloadInfo>? taskProgress, bool? showProgress}) {
|
||||
return DownloadState(
|
||||
downloadStatus: downloadStatus ?? this.downloadStatus,
|
||||
taskProgress: taskProgress ?? this.taskProgress,
|
||||
showProgress: showProgress ?? this.showProgress,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant DownloadState other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
final mapEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return other.downloadStatus == downloadStatus &&
|
||||
mapEquals(other.taskProgress, taskProgress) &&
|
||||
other.showProgress == showProgress;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode;
|
||||
}
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'download_state.model.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class DownloadInfo with _$DownloadInfo {
|
||||
const factory DownloadInfo({required String fileName, required double progress, required TaskStatus status}) =
|
||||
_DownloadInfo;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class DownloadState with _$DownloadState {
|
||||
const factory DownloadState({
|
||||
required TaskStatus downloadStatus,
|
||||
required Map<String, DownloadInfo> taskProgress,
|
||||
required bool showProgress,
|
||||
}) = _DownloadState;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,20 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
/// A wrapper for [CuratedLocationsResponseDto] objects
|
||||
/// and [CuratedObjectsResponseDto] to be displayed in
|
||||
/// a view
|
||||
class SearchCuratedContent {
|
||||
/// The label to show associated with this curated object
|
||||
final String label;
|
||||
|
||||
/// The subtitle to show below the label
|
||||
final String? subtitle;
|
||||
|
||||
/// The id to lookup the asset from the server
|
||||
final String id;
|
||||
|
||||
const SearchCuratedContent({required this.label, required this.id, this.subtitle});
|
||||
|
||||
SearchCuratedContent copyWith({String? label, String? subtitle, String? id}) {
|
||||
return SearchCuratedContent(label: label ?? this.label, subtitle: subtitle ?? this.subtitle, id: id ?? this.id);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{'label': label, 'subtitle': subtitle, 'id': id};
|
||||
}
|
||||
|
||||
factory SearchCuratedContent.fromMap(Map<String, dynamic> map) {
|
||||
return SearchCuratedContent(
|
||||
label: map['label'] as String,
|
||||
subtitle: map['subtitle'] as String?,
|
||||
id: map['id'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchCuratedContent.fromJson(String source) =>
|
||||
SearchCuratedContent.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'CuratedContent(label: $label, subtitle: $subtitle, id: $id)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchCuratedContent other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.label == label && other.subtitle == subtitle && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => label.hashCode ^ id.hashCode;
|
||||
}
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'search_curated_content.model.freezed.dart';
|
||||
|
||||
/// A wrapper for [CuratedLocationsResponseDto] objects
|
||||
/// and [CuratedObjectsResponseDto] to be displayed in
|
||||
/// a view
|
||||
@freezed
|
||||
abstract class SearchCuratedContent with _$SearchCuratedContent {
|
||||
const factory SearchCuratedContent({
|
||||
/// The label to show associated with this curated object
|
||||
required String label,
|
||||
|
||||
/// The id to lookup the asset from the server
|
||||
required String id,
|
||||
|
||||
/// The subtitle to show below the label
|
||||
String? subtitle,
|
||||
}) = _SearchCuratedContent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
part 'search_filter.model.freezed.dart';
|
||||
|
||||
class SearchLocationFilter {
|
||||
String? country;
|
||||
String? state;
|
||||
|
|
@ -177,52 +180,10 @@ class SearchRatingFilter {
|
|||
int get hashCode => rating.hashCode;
|
||||
}
|
||||
|
||||
class SearchDisplayFilters {
|
||||
bool isNotInAlbum = false;
|
||||
bool isArchive = false;
|
||||
bool isFavorite = false;
|
||||
SearchDisplayFilters({required this.isNotInAlbum, required this.isArchive, required this.isFavorite});
|
||||
|
||||
SearchDisplayFilters copyWith({bool? isNotInAlbum, bool? isArchive, bool? isFavorite}) {
|
||||
return SearchDisplayFilters(
|
||||
isNotInAlbum: isNotInAlbum ?? this.isNotInAlbum,
|
||||
isArchive: isArchive ?? this.isArchive,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{'isNotInAlbum': isNotInAlbum, 'isArchive': isArchive, 'isFavorite': isFavorite};
|
||||
}
|
||||
|
||||
factory SearchDisplayFilters.fromMap(Map<String, dynamic> map) {
|
||||
return SearchDisplayFilters(
|
||||
isNotInAlbum: map['isNotInAlbum'] as bool,
|
||||
isArchive: map['isArchive'] as bool,
|
||||
isFavorite: map['isFavorite'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchDisplayFilters.fromJson(String source) =>
|
||||
SearchDisplayFilters.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SearchDisplayFilters(isNotInAlbum: $isNotInAlbum, isArchive: $isArchive, isFavorite: $isFavorite)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchDisplayFilters other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other.isNotInAlbum == isNotInAlbum && other.isArchive == isArchive && other.isFavorite == isFavorite;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isNotInAlbum.hashCode ^ isArchive.hashCode ^ isFavorite.hashCode;
|
||||
@freezed
|
||||
abstract class SearchDisplayFilters with _$SearchDisplayFilters {
|
||||
const factory SearchDisplayFilters({required bool isNotInAlbum, required bool isArchive, required bool isFavorite}) =
|
||||
_SearchDisplayFilters;
|
||||
}
|
||||
|
||||
class SearchFilter {
|
||||
|
|
@ -233,7 +194,7 @@ class SearchFilter {
|
|||
String? language;
|
||||
String? assetId;
|
||||
List<String>? tagIds;
|
||||
Set<PersonDto> people;
|
||||
Set<Person> people;
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
SearchDateFilter date;
|
||||
|
|
@ -289,7 +250,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +128,7 @@ 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,
|
||||
|
|
@ -157,7 +156,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class SimilarPhotosAction extends ActionBuilder {
|
|||
location: .new(),
|
||||
camera: .new(),
|
||||
date: .new(),
|
||||
display: .new(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
display: const .new(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: .new(),
|
||||
mediaType: .other,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,7 +56,7 @@ 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, __) {
|
||||
final db = ref.watch(driftProvider);
|
||||
|
|
@ -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,7 +136,7 @@ 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, __) {
|
||||
final db = ref.watch(driftProvider);
|
||||
|
|
@ -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,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:hooks_riverpod/hooks_riverpod.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 +33,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 +49,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),
|
||||
|
|
@ -107,7 +107,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),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
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