Merge branch 'main' into fix/29468-share-filename

This commit is contained in:
Santo Shakil 2026-08-17 16:02:29 +06:00 committed by GitHub
commit 0ae86ccbc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
420 changed files with 5720 additions and 7090 deletions

View file

@ -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 }}

View file

@ -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

View file

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

View file

@ -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}}'

View file

@ -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()

View file

@ -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: |

View file

@ -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

View file

@ -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: |

View file

@ -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

View file

@ -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: |

View file

@ -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

View file

@ -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"

View file

@ -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();

View file

@ -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

View file

@ -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"
}
]

View file

@ -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,
},
},

View file

@ -1,651 +0,0 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/duplicates', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
]);
});
beforeEach(async () => {
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
// Note: We don't reset users since they're set up once in beforeAll
// Stack must be reset before asset due to foreign key constraint
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
});
describe('GET /duplicates', () => {
it('should return empty array when no duplicates', async () => {
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
// Create assets with different file sizes for duplicate detection
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Manually set duplicateId on both assets to create a duplicate group
const duplicateId = '00000000-0000-4000-8000-000000000001';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{
duplicateId,
assets: expect.arrayContaining([
expect.objectContaining({ id: asset1.id }),
expect.objectContaining({ id: asset2.id }),
]),
suggestedKeepAssetIds: expect.any(Array),
},
]);
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
});
});
describe('POST /duplicates/resolve', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return failure for non-existent duplicate group', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId: uuidDto.dummy,
status: 'FAILED',
reason: expect.stringContaining('not found or access denied'),
},
],
});
});
it('should resolve duplicate group with keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000002';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify side effects: duplicateId cleared on kept asset
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Verify side effects: trashed asset is trashed and duplicateId cleared
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
expect(trashedAsset.duplicateId).toBeNull();
});
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000003';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('disjoint');
});
it('should require keepAssetIds when partially trashing', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000004';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject partial resolution (not all assets covered)', async () => {
const [asset1, asset2, asset3] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000010';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject asset not in duplicate group', async () => {
const [asset1, asset2, outsideAsset] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000011';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should allow trash-all without keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000012';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify both assets are trashed
const [asset1Info, asset2Info] = await Promise.all([
utils.getAssetInfo(user1.accessToken, asset1.id),
utils.getAssetInfo(user1.accessToken, asset2.id),
]);
expect(asset1Info.isTrashed).toBe(true);
expect(asset1Info.duplicateId).toBeNull();
expect(asset2Info.isTrashed).toBe(true);
expect(asset2Info.duplicateId).toBeNull();
});
it('should reject cross-user duplicate group access', async () => {
const asset1 = await utils.createAsset(user1.accessToken);
const asset2 = await utils.createAsset(user2.accessToken);
const duplicateId = '00000000-0000-4000-8000-000000000013';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
// User1 tries to resolve a group containing user2's asset
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should synchronize favorites when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Mark one asset as favorite
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], isFavorite: true });
const duplicateId = '00000000-0000-4000-8000-000000000020';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify favorite was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.isFavorite).toBe(true);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize visibility when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Archive one asset
await utils.archiveAssets(user1.accessToken, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000021';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify visibility was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.visibility).toBe('archive');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize rating when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set rating on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], rating: 5 });
const duplicateId = '00000000-0000-4000-8000-000000000022';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify rating was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.rating).toBe(5);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize description when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set description on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
const duplicateId = '00000000-0000-4000-8000-000000000023';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify description was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize location when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set location on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
const duplicateId = '00000000-0000-4000-8000-000000000024';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify location was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize albums when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Create albums and add assets to different albums
const album1 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 1',
assetIds: [asset1.id],
});
const album2 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 2',
assetIds: [asset2.id],
});
const duplicateId = '00000000-0000-4000-8000-000000000025';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper is now in both albums
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Check albums directly
const { status: album1Status, body: album1Body } = await request(app)
.get(`/albums/${album1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
const { status: album2Status, body: album2Body } = await request(app)
.get(`/albums/${album2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(album1Status).toBe(200);
expect(album2Status).toBe(200);
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
});
it('should synchronize tags when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Wait for metadata extraction to complete before adding tags
// Otherwise, metadata jobs will race and overwrite our tags
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
// Create tags and tag assets differently
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000026';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper has both tags
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
expect(keptAsset.tags).toBeDefined();
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
expect(tagIds).toContain(tags[0].id);
expect(tagIds).toContain(tags[1].id);
});
it('should handle batch resolve with mixed success and failure', async () => {
// Create first group that will succeed
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
// Create second group with non-existent duplicate ID (will fail)
const fakeId = '00000000-0000-4000-8000-000000000099';
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
],
});
expect(status).toBe(200);
expect(body.status).toBe('COMPLETED');
expect(body.results).toHaveLength(2);
// First group should succeed
expect(body.results[0].duplicateId).toBe(duplicateId1);
expect(body.results[0].status).toBe('SUCCESS');
// Second group should fail
expect(body.results[1].duplicateId).toBe(fakeId);
expect(body.results[1].status).toBe('FAILED');
expect(body.results[1].reason).toContain('not found or access denied');
// Verify first group was actually resolved despite second failure
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(asset1Info.duplicateId).toBeNull();
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(asset2Info.isTrashed).toBe(true);
});
it('should trash assets when trash is enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000028';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Ensure trash is enabled (default)
const config = await utils.getSystemConfig(admin.accessToken);
expect(config.trash.enabled).toBe(true);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify asset is trashed (not deleted)
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
});
it('should delete assets when trash is disabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000029';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Disable trash
await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
trash: { enabled: false, days: 30 },
});
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Asset should be marked as deleted (force delete)
const { status: getStatus } = await request(app)
.get(`/assets/${asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
// Asset should still be accessible (soft deleted) but marked as deleted
expect(getStatus).toBe(200);
// Re-enable trash for other tests
await utils.resetAdminConfig(admin.accessToken);
});
});
});

View file

@ -1,9 +1,7 @@
export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
dummy: '00000000-0000-4000-a000-000000000001',
dummy2: '00000000-0000-4000-a000-000000000002',
};
const adminLoginDto = {
@ -57,22 +55,6 @@ export const createUserDto = {
};
export const userDto = {
admin: {
name: signupDto.admin.name,
email: signupDto.admin.email,
password: signupDto.admin.password,
storageLabel: 'admin',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
user1: {
name: createUserDto.user1.name,
email: createUserDto.user1.email,

View file

@ -40,41 +40,6 @@ export const errorDto = {
},
};
export const signupResponseDto = {
admin: {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.cloud',
storageLabel: 'admin',
profileImagePath: '',
// why? lol
shouldChangePassword: true,
isAdmin: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',
license: null,
profileChangedAt: expect.any(String),
},
};
export const loginResponseDto = {
admin: {
accessToken: expect.any(String),
name: 'Immich Admin',
isAdmin: true,
isOnboarded: false,
profileImagePath: '',
shouldChangePassword: true,
userEmail: 'admin@immich.cloud',
userId: expect.any(String),
},
};
export const deviceDto = {
current: {
id: expect.any(String),

View file

@ -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}`)

View file

@ -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')

View file

@ -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 });

View file

@ -137,13 +137,6 @@ describe('/shared-links', () => {
});
describe('GET /shared-links', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/shared-links');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get all shared links created by user', async () => {
const { status, body } = await request(app)
.get('/shared-links')
@ -200,12 +193,6 @@ describe('/shared-links', () => {
});
describe('GET /shared-links/me', () => {
it('should not require admin authentication', async () => {
const { status } = await request(app).get('/shared-links/me').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(403);
});
it('should get data for correct shared link', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
@ -293,13 +280,6 @@ describe('/shared-links', () => {
});
describe('GET /shared-links/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/shared-links/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get shared link by id', async () => {
const { status, body } = await request(app)
.get(`/shared-links/${linkWithAlbum.id}`)
@ -326,26 +306,6 @@ describe('/shared-links', () => {
});
describe('POST /shared-links', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.send({ type: SharedLinkType.Album, albumId: uuidDto.notFound });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a type and the correspondent asset/album id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
);
});
it('should require an asset/album id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
@ -353,17 +313,17 @@ describe('/shared-links', () => {
.send({ type: SharedLinkType.Album });
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }]));
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
.send({ type: SharedLinkType.Individual, assetIds: [uuidDto.notFound] });
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
expect(body).toEqual(expect.objectContaining({ message: 'Not found or no asset.share access' }));
});
it('should create a shared link', async () => {
@ -383,15 +343,6 @@ describe('/shared-links', () => {
});
describe('PATCH /shared-links/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.patch(`/shared-links/${linkWithAlbum.id}`)
.send({ description: 'foo' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should fail if invalid link', async () => {
const { status, body } = await request(app)
.patch(`/shared-links/${uuidDto.notFound}`)
@ -474,13 +425,6 @@ describe('/shared-links', () => {
});
describe('DELETE /shared-links/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/shared-links/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should fail if invalid link', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${uuidDto.notFound}`)

View file

@ -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)

View file

@ -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')

View file

@ -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(

View file

@ -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`)

View file

@ -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);
});
});
});

View file

@ -24,13 +24,6 @@ export const ASPECT_RATIO_WEIGHTS = {
'3:1': 0.01, // 1% 3:1 panorama
} as const;
export type AspectRatio = {
width: number;
height: number;
ratio: number;
name: string;
};
// Mock configuration for asset generation - will be transformed to API response formats
export type MockTimelineAsset = {
id: string;

View file

@ -1,4 +1,4 @@
import { BrowserContext, expect, Page } from '@playwright/test';
import { expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/ui/generators/timeline';
@ -11,18 +11,6 @@ export const padYearMonth = (yearMonth: string) => {
return `${year}-${month.padStart(2, '0')}`;
};
export async function throttlePage(context: BrowserContext, page: Page) {
const session = await context.newCDPSession(page);
await session.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.5 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 40,
connectionType: 'cellular3g',
});
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
}
export const poll = async <T>(
page: Page,
query: () => Promise<T>,

View file

@ -1788,6 +1788,7 @@
"restore_all": "Restore all",
"restore_user": "Restore user",
"restored_asset": "Restored asset",
"result": "Result",
"resume": "Resume",
"resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
"review_duplicates": "Review duplicates",
@ -2139,6 +2140,8 @@
"trigger": "Trigger",
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
"trigger_asset_tagged": "Asset Tagged",
"trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
"trigger_asset_uploaded": "Asset Upload",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_person_recognized": "Person Recognized",
@ -2276,6 +2279,13 @@
"workflow_info": "Workflow info",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_logging_completed": "Completed",
"workflow_logging_disable": "Disable logging",
"workflow_logging_disabled_description": "Logging is currently disabled for this workflow.",
"workflow_logging_enable": "Enable logging",
"workflow_logging_error_step": "Error on step #{step}",
"workflow_logging_halted": "Halted",
"workflow_logging_halted_step": "Halted on step #{step}",
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",

View file

@ -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]]

View file

@ -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"

View file

@ -26,52 +26,59 @@ linter:
# producing the lint.
rules:
# Formatting
avoid_print: true
require_trailing_commas: true
unrelated_type_equality_checks: true
prefer_const_constructors: true
always_use_package_imports: true
always_put_control_body_on_new_line: true
# Correctness
always_declare_return_types: true
avoid_type_to_string: true
avoid_unused_constructor_parameters: true
avoid_void_async: true
cancel_subscriptions: true
cast_nullable_to_non_nullable: true
close_sinks: true
collection_methods_unrelated_type: true
deprecated_consistency: true
discarded_futures: true
no_adjacent_strings_in_list: true
no_self_assignments: true
noop_primitive_operations: true
only_throw_errors: true
parameter_assignments: true
throw_in_finally: true
tighten_type_of_initializing_formals: true
unawaited_futures: true
unnecessary_ignore: true
unnecessary_null_checks: true
unnecessary_parenthesis: true
prefer_final_locals: true
unnecessary_statements: true
unrelated_type_equality_checks: true
# Performance
avoid_slow_async_io: true
prefer_const_constructors: true
prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true
use_super_parameters: true
directives_ordering: true
no_leading_underscores_for_local_identifiers: true
always_declare_return_types: true
avoid_void_async: true
noop_primitive_operations: true
use_named_constants: true
combinators_ordering: true
avoid_multiple_declarations_per_line: true
unnecessary_breaks: true
# Correctness
no_adjacent_strings_in_list: true
cancel_subscriptions: true
close_sinks: true
unawaited_futures: true
discarded_futures: true
no_self_assignments: true
throw_in_finally: true
collection_methods_unrelated_type: true
cast_nullable_to_non_nullable: true
# Known issues
avoid_slow_async_io: true
avoid_type_to_string: true
# Flutter specific
use_build_context_synchronously: true
avoid_unnecessary_containers: true
sized_box_for_whitespace: true
use_build_context_synchronously: true
use_colored_box: true
use_decorated_box: true
avoid_unnecessary_containers: true
use_full_hex_values_for_flutter_colors: true
# Style
always_put_control_body_on_new_line: true
always_use_package_imports: true
avoid_multiple_declarations_per_line: true
avoid_print: true
combinators_ordering: true
directives_ordering: true
no_leading_underscores_for_local_identifiers: true
prefer_final_locals: true
require_trailing_commas: true
unnecessary_breaks: true
unnecessary_parenthesis: true
use_named_constants: true
use_super_parameters: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
analyzer:
@ -97,6 +104,18 @@ dart_code_metrics:
exclude-paths:
- 'lib/utils/debug_print.dart'
severity: perf
- name: tr
description: 'Use context.t.<key> or StaticTranslations.instance.<key> instead of tr().'
- type: String
entries:
- name: tr
description: 'Use context.t.<key> or StaticTranslations.instance.<key> instead of String.tr().'
- name: plural
description: 'Use the generated plural accessor instead of String.plural().'
- type: Text
entries:
- name: tr
description: 'Use Text(context.t.<key>) instead of Text(<key>).tr().'
# All rules from "recommended" preset
# Show potential errors
# - avoid-cascade-after-if-null

View file

@ -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';

View file

@ -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);
});
});
}

View file

@ -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);

View file

@ -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();

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';

View file

@ -3,32 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.model.freezed.dart';
// TODO: Remove PersonDto once Isar is removed
@freezed
abstract class PersonDto with _$PersonDto {
const factory PersonDto({
required String id,
DateTime? birthDate,
required bool isHidden,
required String name,
required String thumbnailPath,
DateTime? updatedAt,
}) = _PersonDto;
}
// Model for a person stored in the server
@freezed
abstract class DriftPerson with _$DriftPerson {
const factory DriftPerson({
required String id,
required DateTime createdAt,
required DateTime updatedAt,
required String ownerId,
required String name,
String? faceAssetId,
required bool isFavorite,
required bool isHidden,
required String? color,
DateTime? birthDate,
}) = _DriftPerson;
abstract class Person with _$Person {
const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person;
}

View file

@ -0,0 +1,23 @@
import 'package:immich_mobile/utils/semver.dart';
enum ServerCapability {
// Feature Support
cloudIdMetadata(SemVer(major: 2, minor: 4, patch: 0)),
bulkCloudIdMetadata(SemVer(major: 2, minor: 5, patch: 0)),
assetEdits(SemVer(major: 2, minor: 6, patch: 0)),
assetFacesV2(SemVer(major: 2, minor: 6, patch: 0)),
syncV2(SemVer(major: 3, minor: 0, patch: 0)),
assetOcr(SemVer(major: 3, minor: 0, patch: 0)),
// Migrations
assetPayloadChange20260128(SemVer(major: 2, minor: 5, patch: 0)),
assetPayloadChange20260597(SemVer(major: 2, minor: 7, patch: 6));
const ServerCapability(this.minVersion);
final SemVer minVersion;
}
extension ServerCapabilitySupport on SemVer {
bool supports(ServerCapability capability) => this >= capability.minVersion;
}

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:ui';
import 'package:freezed_annotation/freezed_annotation.dart';

View file

@ -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);

View file

@ -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);
}

View file

@ -4,6 +4,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@ -122,7 +123,7 @@ class SyncStreamService {
}
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
semVer.supports(.assetPayloadChange20260128)) {
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetV1,
@ -139,7 +140,7 @@ class SyncStreamService {
}
if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) &&
semVer > const SemVer(major: 2, minor: 7, patch: 5)) {
semVer.supports(.assetPayloadChange20260597)) {
_logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2");
await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]);
migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name);

View file

@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@ -31,12 +32,12 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
await _populateCloudIds(db);
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
final canUpdateMetadata = serverInfo.serverVersion.supports(.cloudIdMetadata);
if (!canUpdateMetadata) {
logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
return;
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
final canBulkUpdateMetadata = serverInfo.serverVersion.supports(.bulkCloudIdMetadata);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
@ -44,25 +45,16 @@ class SyncApiRepository {
types: [
SyncRequestType.authUsersV1,
SyncRequestType.usersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.assetsV2
: SyncRequestType.assetsV1,
serverVersion.supports(.syncV2) ? SyncRequestType.assetsV2 : SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
if (serverVersion.supports(.assetEdits)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.partnerAssetsV2
: SyncRequestType.partnerAssetsV1,
serverVersion.supports(.syncV2) ? SyncRequestType.partnerAssetsV2 : SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1,
if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0))
SyncRequestType.albumsV1
else
SyncRequestType.albumsV2,
serverVersion.supports(.syncV2) ? SyncRequestType.albumsV2 : SyncRequestType.albumsV1,
SyncRequestType.albumUsersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.albumAssetsV2
: SyncRequestType.albumAssetsV1,
serverVersion.supports(.syncV2) ? SyncRequestType.albumAssetsV2 : SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetExifsV1,
SyncRequestType.albumToAssetsV1,
SyncRequestType.memoriesV1,
@ -71,10 +63,8 @@ class SyncApiRepository {
SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1,
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
? SyncRequestType.assetFacesV2
: SyncRequestType.assetFacesV1,
if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
serverVersion.supports(.assetFacesV2) ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1,
if (serverVersion.supports(.assetOcr)) SyncRequestType.assetOcrV1,
],
).toJson(),
);

View file

@ -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()],
),
),
);

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:background_downloader/background_downloader.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
enum LivePhotosPart { video, image }

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -194,7 +193,7 @@ class SearchFilter {
String? language;
String? assetId;
List<String>? tagIds;
Set<PersonDto> people;
Set<Person> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
@ -250,7 +249,7 @@ class SearchFilter {
String? language,
String? ocr,
String? assetId,
Set<PersonDto>? people,
Set<Person>? people,
List<String>? tagIds,
SearchLocationFilter? location,
SearchCameraFilter? camera,

View file

@ -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,
};
}

View file

@ -6,8 +6,4 @@ class ServerVersion extends SemVer {
ServerVersion.fromDto(ServerVersionResponseDto dto)
: super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) {
return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
}
}

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'dart:io';

View file

@ -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),

View file

@ -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)),
),
),

View file

@ -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()));

View file

@ -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(),
),
);

View file

@ -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),
),
),

View file

@ -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: [

View file

@ -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: [

View file

@ -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(

View file

@ -1,5 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -64,7 +63,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
headers.value = headers.value.toList();
},
icon: const Icon(Icons.add_outlined),
tooltip: 'header_settings_add_header_tip'.tr(),
tooltip: context.t.header_settings_add_header_tip,
),
],
),
@ -111,7 +110,7 @@ class HeaderKeyValueSettings extends StatelessWidget {
String? emptyFieldValidator(String? value) {
if (value == null || value.isEmpty) {
return 'header_settings_field_validator_msg'.tr();
return StaticTranslations.instance.header_settings_field_validator_msg;
}
return null;
@ -129,10 +128,12 @@ class HeaderKeyValueSettings extends StatelessWidget {
child: TextFormField(
controller: keyController,
decoration: InputDecoration(
labelText: 'header_settings_header_name_input'.tr(),
labelText: context.t.header_settings_header_name_input,
border: const OutlineInputBorder(),
),
autocorrect: false,
smartDashesType: .disabled,
smartQuotesType: .disabled,
onChanged: (headerKey) {
header.key = headerKey;
},
@ -157,10 +158,12 @@ class HeaderKeyValueSettings extends StatelessWidget {
child: TextFormField(
controller: valueController,
decoration: InputDecoration(
labelText: 'header_settings_header_value_input'.tr(),
labelText: context.t.header_settings_header_value_input,
border: const OutlineInputBorder(),
),
autocorrect: false,
smartDashesType: .disabled,
smartQuotesType: .disabled,
onChanged: (headerValue) {
header.value = headerValue;
},

View file

@ -54,7 +54,7 @@ class LargeLeadingTile extends StatelessWidget {
],
),
),
if (trailing != null) trailing!,
?trailing,
],
),
),

View file

@ -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,
);
}

View file

@ -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();

View file

@ -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));
},
),
),

View file

@ -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),
),
),

View file

@ -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(),

View file

@ -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);

View file

@ -2,12 +2,12 @@ import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@ -140,7 +140,7 @@ class _BottomBar extends StatelessWidget {
const SizedBox(width: 15),
ValueListenableBuilder(
valueListenable: selectedLatLng,
builder: (_, value, __) =>
builder: (_, value, _) =>
Text("${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}"),
),
],
@ -148,10 +148,7 @@ class _BottomBar extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: onUseLocation,
child: const Text("map_location_picker_page_use_location").tr(),
),
ElevatedButton(onPressed: onUseLocation, child: Text(context.t.map_location_picker_page_use_location)),
ElevatedButton(onPressed: onGetCurrentLocation, child: const Icon(Icons.my_location)),
],
),

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
@ -16,12 +17,9 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_mobile/utils/semver.dart';
const _minimumServerVersion = SemVer(major: 2, minor: 6, patch: 0);
final _stateProvider = Provider.family.autoDispose<RemoteAsset?, ActionSource>((ref, source) {
final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion >= _minimumServerVersion));
final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion.supports(.assetEdits)));
if (!isSupported) {
return null;
}

View file

@ -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,

View file

@ -1,9 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@ -56,9 +56,9 @@ class LocalMediaSummaryPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('local_media_summary'.tr())),
appBar: AppBar(title: Text(context.t.local_media_summary)),
body: Consumer(
builder: (ctx, ref, __) {
builder: (ctx, ref, _) {
final db = ref.watch(driftProvider);
final albumsFuture = ref.watch(localAlbumRepository).getAll();
@ -79,7 +79,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
child: Text(ctx.t.album_summary, style: ctx.textTheme.titleMedium),
),
],
),
@ -136,9 +136,9 @@ class RemoteMediaSummaryPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('remote_media_summary'.tr())),
appBar: AppBar(title: Text(context.t.remote_media_summary)),
body: Consumer(
builder: (ctx, ref, __) {
builder: (ctx, ref, _) {
final db = ref.watch(driftProvider);
final albumsFuture = ref.watch(remoteAlbumRepository).getAll();
@ -159,7 +159,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
child: Text(ctx.t.album_summary, style: ctx.textTheme.titleMedium),
),
],
),

View file

@ -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),
),
),

View file

@ -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),

View file

@ -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(),
),
);

View file

@ -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),
),
),

View file

@ -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,
),
),
);

View file

@ -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(),
),
);

View file

@ -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)),
);
},

View file

@ -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)),

View file

@ -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(),
),
),

View file

@ -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(

View file

@ -1,11 +1,12 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@ -33,7 +34,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
@override
Widget build(BuildContext context) {
final people = ref.watch(driftGetAllPeopleProvider);
final people = ref.watch(getAllPeopleProvider);
return LayoutBuilder(
builder: (context, constraints) {
@ -49,10 +50,10 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
onTapOutside: (_) => _formFocus.unfocus(),
onChanged: (value) => setState(() => _search = value),
filled: true,
hintText: 'filter_people'.tr(),
hintText: context.t.filter_people,
autofocus: true,
)
: Text('people'.tr()),
: Text(context.t.people),
actions: [
IconButton(
icon: Icon(_search != null ? Icons.close : Icons.search),
@ -65,12 +66,15 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
body: SafeArea(
child: people.when(
data: (people) {
final List<Person> filtered;
if (_search != null) {
people = people.where((person) {
filtered = people.where((person) {
return person.name.toLowerCase().removeDiacritics().contains(
_search!.toLowerCase().removeDiacritics(),
);
}).toList();
} else {
filtered = people;
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
@ -79,9 +83,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
mainAxisSpacing: isPortrait && isTablet ? 36 : 0,
),
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: people.length,
itemCount: filtered.length,
itemBuilder: (context, index) {
final person = people[index];
final person = filtered[index];
return Column(
key: ValueKey(person.id),
@ -107,7 +111,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
onTap: () => showNameEditModal(context, person),
child: person.name.isEmpty
? Text(
'add_a_name'.tr(),
context.t.add_a_name,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.primary,

View file

@ -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() {

View file

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@ -62,10 +62,10 @@ class _PlaceSliverAppBar extends HookWidget {
onTapOutside: (_) => searchFocusNode.unfocus(),
onChanged: (value) => search.value = value,
filled: true,
hintText: 'filter_places'.t(context: context),
hintText: context.t.filter_places,
autofocus: true,
)
: Text('places'.t(context: context)),
: Text(context.t.places),
actions: [
IconButton(
icon: Icon(search.value != null ? Icons.close : Icons.search),
@ -94,7 +94,7 @@ class _Map extends StatelessWidget {
height: 200,
width: context.width,
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
onTap: (_, _) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
zoom: 8,
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
showAttribution: false,
@ -134,16 +134,19 @@ class _PlaceList extends ConsumerWidget {
),
),
data: (places) {
final List<(String, String)> filtered;
if (search.value != null) {
places = places.where((place) {
filtered = places.where((place) {
return place.$1.toLowerCase().contains(search.value!.toLowerCase());
}).toList();
} else {
filtered = places;
}
return SliverList.builder(
itemCount: places.length,
itemCount: filtered.length,
itemBuilder: (context, index) {
final place = places[index];
final place = filtered[index];
return _PlaceTile(place: place);
},
);

View file

@ -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)),
);
}
}

View file

@ -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)),
);
}
}

View file

@ -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)),
],
),
],

View file

@ -7,11 +7,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
@ -295,14 +296,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
color: context.colorScheme.primary,
);
} else {
return LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: BorderRadius.zero,
minHeight: 5,
value:
ref.read(videoPlayerProvider(asset.id).select((s) => s.position)).inMilliseconds /
asset.duration.inMilliseconds,
);
return _VideoProgressBar(asset: asset);
}
}
@ -374,25 +368,13 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
);
} else {
final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status));
final position = ref.read(videoPlayerProvider(asset.id)).position;
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
unawaited(_nextPage());
} else if (status == VideoPlaybackStatus.playing) {
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false));
}
return PhotoView.customChild(
onTapUp: (_, _, _) => _onTapUp(),
disableScaleGestures: true,
filterQuality: FilterQuality.high,
initialScale: scale,
child: NativeVideoViewer(
asset: asset,
isCurrent: isCurrent,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
return _VideoChild(
asset: asset,
isCurrent: isCurrent,
scale: scale,
imageProvider: imageProvider,
onTapUp: _onTapUp,
onCompleted: _nextPage,
);
}
}
@ -411,7 +393,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
children: [
AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("slideshow".t(context: context)),
title: Text(context.t.slideshow),
actions: [
IconButton(
onPressed: _paused ? _play : _pause,
@ -477,6 +459,67 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
}
class _VideoChild extends ConsumerWidget {
final BaseAsset asset;
final bool isCurrent;
final PhotoViewComputedScale scale;
final ImageProvider imageProvider;
final VoidCallback onTapUp;
final VoidCallback onCompleted;
const _VideoChild({
required this.asset,
required this.isCurrent,
required this.scale,
required this.imageProvider,
required this.onTapUp,
required this.onCompleted,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) {
if (status == VideoPlaybackStatus.completed) {
if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) {
onCompleted();
}
} else if (status == VideoPlaybackStatus.playing) {
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false));
}
});
return PhotoView.customChild(
onTapUp: (_, _, _) => onTapUp(),
disableScaleGestures: true,
filterQuality: FilterQuality.high,
initialScale: scale,
child: NativeVideoViewer(
asset: asset,
isCurrent: isCurrent,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
}
class _VideoProgressBar extends ConsumerWidget {
final BaseAsset asset;
const _VideoProgressBar({required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position));
return LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: BorderRadius.zero,
minHeight: 5,
value: position.inMilliseconds / asset.duration.inMilliseconds,
);
}
}
/// Progress bar for image slides, driven by an explicit [AnimationController].
///
/// [TweenAnimationBuilder] creates its controller internally with the default

View file

@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart';
@ -35,7 +34,7 @@ class DriftTrashPage extends StatelessWidget {
],
child: Timeline(
appBar: SliverAppBar(
title: Text('trash'.t(context: context)),
title: Text(context.t.trash),
floating: true,
snap: true,
pinned: true,

View file

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -9,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -52,6 +52,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<List<UserDto>> suggestedShareUsers = ref.watch(driftUsersProvider);
final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id));
final sharedUsersList = useState<Set<UserDto>>({});
void addNewUsersHandler() {
@ -86,7 +87,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'suggestions'.tr(),
context.t.suggestions,
style: const TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
),
),
@ -118,7 +119,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('invite_to_album').tr(),
title: Text(context.t.invite_to_album),
elevation: 0,
centerTitle: false,
leading: IconButton(
@ -130,15 +131,12 @@ class DriftUserSelectionPage extends HookConsumerWidget {
actions: [
TextButton(
onPressed: sharedUsersList.value.isEmpty ? null : () => addNewUsersHandler(),
child: const Text("add", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
child: Text(context.t.add, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
),
],
),
body: suggestedShareUsers.widgetWhen(
onData: (users) {
// Get shared users for this album from the database
final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id));
return sharedUsers.when(
data: (albumSharedUsers) {
// Filter out users that are already shared with this album and the owner

View file

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -26,7 +26,7 @@ class DriftVideoPage extends StatelessWidget {
return timelineService;
}),
],
child: Timeline(appBar: MesmerizingSliverAppBar(title: 'videos'.t())),
child: Timeline(appBar: MesmerizingSliverAppBar(title: context.t.videos)),
);
}
}

View file

@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/aspect_ratios.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/theme/theme_data.dart';
@ -63,14 +64,14 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
return;
}
ImmichToast.show(context: context, msg: 'success'.tr(), toastType: ToastType.success);
ImmichToast.show(context: context, msg: context.t.success, toastType: ToastType.success);
Navigator.of(context).pop();
} catch (e) {
if (!mounted) {
return;
}
ImmichToast.show(context: context, msg: 'error_title'.tr(), toastType: ToastType.error);
ImmichToast.show(context: context, msg: context.t.error_title, toastType: ToastType.error);
} finally {
ref.read(editorStateProvider.notifier).setIsEditing(false);
}
@ -80,17 +81,17 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('editor_discard_edits_title'.tr()),
content: Text('editor_discard_edits_prompt'.tr()),
title: Text(context.t.editor_discard_edits_title),
content: Text(context.t.editor_discard_edits_prompt),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
style: ButtonStyle(
foregroundColor: WidgetStateProperty.all(context.themeData.colorScheme.onSurfaceVariant),
),
child: Text('cancel'.tr()),
child: Text(context.t.cancel),
),
TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text('confirm'.tr())),
TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text(context.t.confirm)),
],
),
);
@ -116,7 +117,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
child: Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text("edit".tr()),
title: Text(context.t.edit),
leading: ImmichCloseButton(onPressed: () => Navigator.of(context).maybePop()),
actions: [_SaveEditsButton(onSave: _saveEditedImage)],
),
@ -336,7 +337,7 @@ class _ResetEditsButton extends ConsumerWidget {
final editorNotifier = ref.watch(editorStateProvider.notifier);
return ImmichTextButton(
labelText: 'reset'.tr(),
labelText: context.t.reset,
onPressed: editorNotifier.resetEdits,
variant: ImmichVariant.ghost,
expanded: false,

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