Merge branch 'main' into feat/album-asset-workflow-trigger

This commit is contained in:
Ben Beckford 2026-08-12 14:32:36 -07:00
commit 927a8a8dcf
350 changed files with 4864 additions and 6903 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,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')
@ -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

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

@ -97,6 +97,18 @@ dart_code_metrics:
exclude-paths:
- 'lib/utils/debug_print.dart'
severity: perf
- name: tr
description: 'Use context.t.<key> or StaticTranslations.instance.<key> instead of tr().'
- type: String
entries:
- name: tr
description: 'Use context.t.<key> or StaticTranslations.instance.<key> instead of String.tr().'
- name: plural
description: 'Use the generated plural accessor instead of String.plural().'
- type: Text
entries:
- name: tr
description: 'Use Text(context.t.<key>) instead of Text(<key>).tr().'
# All rules from "recommended" preset
# Show potential errors
# - avoid-cascade-after-if-null

View file

@ -33,12 +33,28 @@ data class Request(
val callback: (Result<Map<String, Long>?>) -> Unit
)
/** Set [exactSize] to decode at the smallest size that covers [target] without upscaling. */
@RequiresApi(Build.VERSION_CODES.Q)
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0), exactSize: Boolean = false): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
if (exactSize) {
val fillScale = max(
target.width.toDouble() / info.size.width,
target.height.toDouble() / info.size.height
)
val scale = min(1.0, fillScale)
if (scale < 1) {
val width = ceil(info.size.width * scale).toInt()
val height = ceil(info.size.height * scale).toInt()
if (width > 0 && height > 0) {
decoder.setTargetSize(width, height)
}
}
} else {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))

View file

@ -5,6 +5,7 @@ import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import android.util.Size
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
@ -79,6 +80,8 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
url: String,
requestId: Long,
preferEncoded: Boolean,
width: Long?,
height: Long?,
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
@ -102,13 +105,21 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
decodeExecutor.execute {
val res = if (signal.isCanceled) null else try {
val bitmap = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset)).decodeBitmap()
// The embedded preview a raw decodes to has no orientation, so read the container's.
val orientation = if (isRawMime(contentType)) {
readRawOrientation(NativeBuffer.wrap(buffer.pointer, buffer.offset), buffer.offset)
} else {
ExifInterface.ORIENTATION_NORMAL
}
val target = when (orientation) {
ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_TRANSVERSE,
ExifInterface.ORIENTATION_ROTATE_270 -> Size(height?.toInt() ?: 0, width?.toInt() ?: 0)
else -> Size(width?.toInt() ?: 0, height?.toInt() ?: 0)
}
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
val bitmap = source.decodeBitmap(target, exactSize = true)
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
bitmap.toNativeBuffer()
} else {

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

@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:integration_test/integration_test.dart';
import 'test_utils/fake_immich_server.dart';
// Issue #28082 end-to-end: a resume after a sync froze mid-flight starts a fresh sync.
// Kept in its own file on purpose: run after the failed_sync_resume tests in one process,
// the resume sync starves past its window - a cross-test interaction that survived pool,
// sqlite and bg-worker audits unnamed. One file per process is device-proven green.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
late Drift drift;
late FakeImmichServer server;
// main()'s formula with a higher floor: a wedged frozen worker plus a full local
// sync must not starve the fresh resume sync out of its 25s window on 4 cores.
final poolSize = max(Platform.numberOfProcessors - 1, 8);
setUpAll(() async {
await app.initApp();
(drift, _) = await Bootstrap.initDomain();
// A background-worker schedule persisted by real app use on this device can
// launch a second engine mid-file (own isolate pool + full sync) and starve
// these tests on a small device. Unregister it for the whole run.
await BackgroundWorkerFgService(BackgroundWorkerFgHostApi()).disable();
});
setUp(() async {
// A task completing while dispose tears the pool down re-warms it (_schedule
// on the cleared pool re-creates workers), and init on a warm pool is silently
// ignored - poolSize would never apply. Reset first, then verify it took.
await workerManagerPatch.dispose();
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: poolSize);
expect(
workerManagerPatch.pool.length,
poolSize,
reason: 'init was ignored: a straggler from the previous test re-warmed the pool',
);
server = await FakeImmichServer.start();
await ApiService().resolveAndSetEndpoint(server.endpoint);
await drift.delete(drift.userEntity).go();
});
tearDown(() async {
// Close the server first so any held-open sync stream ends and its isolate unwinds,
// then drain the pool - otherwise dispose waits on the frozen read.
await server.close();
await workerManagerPatch.dispose();
});
testWidgets('a resume after a sync froze mid-flight starts a fresh sync', (tester) async {
final manager = BackgroundSyncManager();
// Not disposed on purpose: driftOverride closes the drift on dispose, and that
// drift belongs to setUpAll. The container only holds the shared drift and a
// fire-and-forget resume; the frozen isolates + server are drained by tearDown.
final container = ProviderContainer(
overrides: [driftProvider.overrideWith(driftOverride(drift)), backgroundSyncProvider.overrideWithValue(manager)],
);
// A first sync opens /sync/stream and never finishes - the frozen state a
// suspended sync isolate is left in. Holding the stream open keeps
// _syncTask non-null, exactly as it is across an iOS process suspension.
unawaited(manager.syncRemote());
await server
.streamOpenedNth(1)
.timeout(const Duration(seconds: 30), onTimeout: () => fail('first sync isolate never opened /sync/stream'));
// The lifecycle then goes background -> foreground. handleAppResume runs the
// resume sync exactly once. On the buggy build it hangs on the stale task's
// future, so it is not awaited here.
final notifier = container.read(appStateProvider.notifier);
await notifier.handleAppPause();
unawaited(notifier.handleAppResume());
await server
.streamOpenedNth(2)
.timeout(
const Duration(seconds: 25),
onTimeout: () => fail('resume did not start a fresh remote sync - the stale frozen sync blocked it (#28082)'),
);
expect(server.streamOpenCount, greaterThanOrEqualTo(2));
});
}

View file

@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:integration_test/integration_test.dart';
import 'test_utils/fake_immich_server.dart';
// Issue #28082: a remote sync in-flight when the app is backgrounded stays referenced
// but frozen across the suspension. On resume the app drops the stale task
// (cancelResumeSyncs) and starts a fresh sync.
//
// Device/emulator tests: real worker isolates + a real drift db + a loopback fake server
// (same pattern as background_sync_teardown_test). The mobile integration-test CI job is
// disabled in test.yml, so like Mert's teardown test this is a local/on-device guard.
//
// The end-to-end resume test lives in failed_sync_resume_e2e_test.dart - one process
// per file keeps it clear of the cross-test interaction described there.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
late Drift drift;
late FakeImmichServer server;
// main()'s formula with a higher floor: a wedged frozen worker plus a full local
// sync must not starve the fresh resume sync out of its 25s window on 4 cores.
final poolSize = max(Platform.numberOfProcessors - 1, 8);
setUpAll(() async {
await app.initApp();
(drift, _) = await Bootstrap.initDomain();
// A background-worker schedule persisted by real app use on this device can
// launch a second engine mid-file (own isolate pool + full sync) and starve
// these tests on a small device. Unregister it for the whole run.
await BackgroundWorkerFgService(BackgroundWorkerFgHostApi()).disable();
});
setUp(() async {
// A task completing while dispose tears the pool down re-warms it (_schedule
// on the cleared pool re-creates workers), and init on a warm pool is silently
// ignored - poolSize would never apply. Reset first, then verify it took.
await workerManagerPatch.dispose();
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: poolSize);
expect(
workerManagerPatch.pool.length,
poolSize,
reason: 'init was ignored: a straggler from the previous test re-warmed the pool',
);
server = await FakeImmichServer.start();
await ApiService().resolveAndSetEndpoint(server.endpoint);
await drift.delete(drift.userEntity).go();
});
tearDown(() async {
// Close the server first so any held-open sync stream ends and its isolate unwinds,
// then drain the pool - otherwise dispose waits on the frozen read.
await server.close();
await workerManagerPatch.dispose();
});
// Self-contained (bare manager, no fire-and-forget resume), so it runs first: its
// frozen syncs are fully drained by tearDown.
testWidgets('a cancelled sync task does not clear the slot of the fresh task that superseded it', (tester) async {
final manager = BackgroundSyncManager();
// First sync opens /sync/stream and is held open - the frozen suspended state.
unawaited(manager.syncRemote());
await server
.streamOpenedNth(1)
.timeout(const Duration(seconds: 30), onTimeout: () => fail('first sync isolate never opened /sync/stream'));
// Resume drops the stale task then immediately starts a fresh one, exactly as
// _handleBetaTimelineResume does. cancelResumeSyncs cancels the first task; its
// completion chain then fires and must NOT null the fresh task's slot.
unawaited(manager.cancelResumeSyncs());
unawaited(manager.syncRemote());
await server
.streamOpenedNth(2)
.timeout(const Duration(seconds: 30), onTimeout: () => fail('fresh sync isolate never opened /sync/stream'));
// A third sync stream only opens if the cancelled task cleared the fresh slot,
// letting the dedupe guard start a redundant sync.
var thirdOpened = false;
unawaited(server.streamOpenedNth(3).then((_) => thirdOpened = true));
// Let the cancelled task's completion chain settle, then ask to sync again.
await Future.delayed(const Duration(milliseconds: 200));
unawaited(manager.syncRemote());
await Future.delayed(const Duration(seconds: 3));
expect(
thirdOpened,
isFalse,
reason: 'the cancelled task cleared the fresh task slot, so a redundant third sync started (#28082 clobber)',
);
expect(server.streamOpenCount, 2);
});
// The false-error-flash fix: a task cancelled by cancelResumeSyncs completes with a
// CanceledError, which is not a sync failure and must not reach onRemoteSyncError.
// Before the filter the stale task reported an error right after the fresh sync
// started, so the status UI showed a failure for the whole healthy run.
testWidgets('a cancelled sync does not report a false error to the status callbacks', (tester) async {
final errors = <String>[];
final manager = BackgroundSyncManager(onRemoteSyncError: errors.add);
// Hold the stream open so the task is genuinely in-flight when it is cancelled.
unawaited(manager.syncRemote());
await server
.streamOpenedNth(1)
.timeout(const Duration(seconds: 30), onTimeout: () => fail('sync isolate never opened /sync/stream'));
await manager.cancelResumeSyncs();
// Let the cancelled task's completion chain run before checking the callbacks.
await Future.delayed(const Duration(milliseconds: 100));
expect(
errors,
isEmpty,
reason: 'a cancelled task is not a real error; its CanceledError must not fire onRemoteSyncError',
);
});
}

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

@ -10,6 +10,8 @@ class FakeImmichServer {
final (int, int, int) version;
final Completer<SyncStream> _streamOpened = Completer<SyncStream>();
final List<SyncStream> _streamOpens = [];
final Map<int, Completer<SyncStream>> _openWaiters = {};
int ackRequests = 0;
@ -18,6 +20,17 @@ class FakeImmichServer {
/// Resolves when the sync isolate opens `POST /sync/stream`.
Future<SyncStream> get streamOpened => _streamOpened.future;
/// How many `/sync/stream` requests have opened so far.
int get streamOpenCount => _streamOpens.length;
/// Resolves when the [n]-th (1-indexed) `/sync/stream` opens.
Future<SyncStream> streamOpenedNth(int n) {
if (_streamOpens.length >= n) {
return Future.value(_streamOpens[n - 1]);
}
return (_openWaiters[n] ??= Completer<SyncStream>()).future;
}
static Future<FakeImmichServer> start({(int, int, int) version = (3, 0, 0)}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = FakeImmichServer._(server, version);
@ -58,13 +71,17 @@ class FakeImmichServer {
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType('application', 'jsonlines+json')
..contentLength = -1 // chunked: stays open to stream incrementally
..contentLength =
-1 // chunked: stays open to stream incrementally
..bufferOutput = false;
// Flush headers so the client's send() resolves and enters its read loop.
await request.response.flush();
final stream = SyncStream._(request.response);
_streamOpens.add(stream);
if (!_streamOpened.isCompleted) {
_streamOpened.complete(SyncStream._(request.response));
_streamOpened.complete(stream);
}
_openWaiters.remove(_streamOpens.length)?.complete(stream);
}
Future<void> _respondJson(HttpRequest request, Object body) async {
@ -83,8 +100,8 @@ class FakeImmichServer {
}
Future<void> close() async {
if (_streamOpened.isCompleted) {
await (await _streamOpened.future).close();
for (final stream in _streamOpens) {
await stream.close();
}
await _server.close(force: true);
}

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,5 +1,6 @@
import Accelerate
import Flutter
import ImageIO
import MobileCoreServices
import Photos
@ -27,21 +28,21 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .perceptual
)!
private static let decodeOptions = [
private static let decodeOptions: [NSString: Bool] = [
kCGImageSourceShouldCache: false,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary
]
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, width: Int64?, height: Int64?, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad
let request = RemoteImageRequest(id: requestId, completion: completion)
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
Self.handleCompletion(request: request, encoded: preferEncoded, data: data, response: response, error: error)
Self.handleCompletion(request: request, encoded: preferEncoded, width: width, height: height, data: data, response: response, error: error)
}
request.task = task
@ -49,7 +50,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
task.resume()
}
private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, width: Int64?, height: Int64?, data: Data?, response: URLResponse?, error: Error?) {
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
@ -87,8 +88,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
return request.completion(ImageProcessing.cancelledResult)
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
registry.remove(requestId: request.id)
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
var options: [NSString: Any] = decodeOptions
if let maxPixelSize = targetThumbnailRenderSize(imageSource: imageSource, width: width, height: height) {
options[kCGImageSourceThumbnailMaxPixelSize] = maxPixelSize
}
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
registry.remove(requestId: request.id)
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
@ -120,6 +130,34 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
}
}
/// Returns the longest rendered edge needed to cover the requested size.
private static func targetThumbnailRenderSize(imageSource: CGImageSource, width: Int64?, height: Int64?) -> Int? {
guard let width,
let height,
width > 0,
height > 0,
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let pixelWidth = properties[kCGImagePropertyPixelWidth] as? NSNumber,
let pixelHeight = properties[kCGImagePropertyPixelHeight] as? NSNumber else {
return nil
}
let orientation = (properties[kCGImagePropertyOrientation] as? NSNumber)
.flatMap { CGImagePropertyOrientation(rawValue: $0.uint32Value) } ?? .up
let swapsDimensions: Bool
switch orientation {
case .leftMirrored, .right, .rightMirrored, .left:
swapsDimensions = true
default:
swapsDimensions = false
}
let sourceWidth = swapsDimensions ? pixelHeight.doubleValue : pixelWidth.doubleValue
let sourceHeight = swapsDimensions ? pixelWidth.doubleValue : pixelHeight.doubleValue
let fillScale = max(Double(width) / sourceWidth, Double(height) / sourceHeight)
let scale = min(1, fillScale)
return scale < 1 ? Int(ceil(max(sourceWidth * scale, sourceHeight * scale))) : nil
}
func cancelRequest(requestId: Int64) {
Self.registry.remove(requestId: requestId)?.cancel()
}

View file

@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
class CropAspectRatio {
final int? numerator;
final int? denominator;
part 'aspect_ratios.freezed.dart';
final String? customLabel;
final IconData? icon;
@freezed
abstract class CropAspectRatio with _$CropAspectRatio {
const CropAspectRatio._();
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon});
const factory CropAspectRatio({int? numerator, int? denominator, String? customLabel, IconData? icon}) =
_CropAspectRatio;
static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
@ -22,24 +23,6 @@ class CropAspectRatio {
CropAspectRatio get flipped {
return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is CropAspectRatio &&
other.numerator == numerator &&
other.denominator == denominator &&
other.customLabel == customLabel &&
other.icon == icon;
}
@override
int get hashCode {
return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode;
}
}
const aspectRatioFree = CropAspectRatio.free;

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,51 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'log.model.freezed.dart';
/// Log levels according to dart logging [Level]
enum LogLevel { all, finest, finer, fine, config, info, warning, severe, shout, off }
class LogMessage {
final String message;
final LogLevel level;
final DateTime createdAt;
final String? logger;
final String? error;
final String? stack;
const LogMessage({
required this.message,
required this.level,
required this.createdAt,
this.logger,
this.error,
this.stack,
});
@override
bool operator ==(covariant LogMessage other) {
if (identical(this, other)) {
return true;
}
return other.message == message &&
other.level == level &&
other.createdAt == createdAt &&
other.logger == logger &&
other.error == error &&
other.stack == stack;
}
@override
int get hashCode {
return message.hashCode ^ level.hashCode ^ createdAt.hashCode ^ logger.hashCode ^ error.hashCode ^ stack.hashCode;
}
@override
String toString() {
return '''LogMessage: {
message: $message,
level: $level,
createdAt: $createdAt,
logger: ${logger ?? '<NA>'},
error: ${error ?? '<NA>'},
stack: ${stack ?? '<NA>'},
}''';
}
@freezed
abstract class LogMessage with _$LogMessage {
const factory LogMessage({
required String message,
required LogLevel level,
required DateTime createdAt,
String? logger,
String? error,
String? stack,
}) = _LogMessage;
}

View file

@ -1,20 +1,9 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class Marker {
final LatLng location;
final String assetId;
part 'map.model.freezed.dart';
const Marker({required this.location, required this.assetId});
@override
bool operator ==(covariant Marker other) {
if (identical(this, other)) {
return true;
}
return other.location == location && other.assetId == assetId;
}
@override
int get hashCode => location.hashCode ^ assetId.hashCode;
@freezed
abstract class Marker with _$Marker {
const factory Marker({required LatLng location, required String assetId}) = _Marker;
}

View file

@ -1,22 +1,22 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
part 'memory.model.freezed.dart';
// TODO(agg23): Remove enum suffix
enum MemoryTypeEnum {
// do not change this order!
onThisDay,
}
class MemoryData {
final int year;
@Freezed(fromJson: false, toJson: false)
abstract class MemoryData with _$MemoryData {
const MemoryData._();
const MemoryData({required this.year});
MemoryData copyWith({int? year}) {
return MemoryData(year: year ?? this.year);
}
const factory MemoryData({required int year}) = _MemoryData;
Map<String, dynamic> toMap() {
return <String, dynamic>{'year': year};
@ -29,144 +29,25 @@ class MemoryData {
String toJson() => json.encode(toMap());
factory MemoryData.fromJson(String source) => MemoryData.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'MemoryData(year: $year)';
@override
bool operator ==(covariant MemoryData other) {
if (identical(this, other)) {
return true;
}
return other.year == year;
}
@override
int get hashCode => year.hashCode;
}
// Model for a memory stored in the server
class DriftMemory {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? deletedAt;
final String ownerId;
// enum
final MemoryTypeEnum type;
final MemoryData data;
final bool isSaved;
final DateTime memoryAt;
final DateTime? seenAt;
final DateTime? showAt;
final DateTime? hideAt;
final List<RemoteAsset> assets;
const DriftMemory({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.ownerId,
required this.type,
required this.data,
required this.isSaved,
required this.memoryAt,
this.seenAt,
this.showAt,
this.hideAt,
required this.assets,
});
DriftMemory copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
// TODO(agg23): DriftMemoryRepository currently mutates `assets`
@Freezed(makeCollectionsUnmodifiable: false)
abstract class DriftMemory with _$DriftMemory {
const factory DriftMemory({
required String id,
required DateTime createdAt,
required DateTime updatedAt,
DateTime? deletedAt,
String? ownerId,
MemoryTypeEnum? type,
MemoryData? data,
bool? isSaved,
DateTime? memoryAt,
required String ownerId,
required MemoryTypeEnum type,
required MemoryData data,
required bool isSaved,
required DateTime memoryAt,
DateTime? seenAt,
DateTime? showAt,
DateTime? hideAt,
List<RemoteAsset>? assets,
}) {
return DriftMemory(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
ownerId: ownerId ?? this.ownerId,
type: type ?? this.type,
data: data ?? this.data,
isSaved: isSaved ?? this.isSaved,
memoryAt: memoryAt ?? this.memoryAt,
seenAt: seenAt ?? this.seenAt,
showAt: showAt ?? this.showAt,
hideAt: hideAt ?? this.hideAt,
assets: assets ?? this.assets,
);
}
@override
String toString() {
return '''Memory {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: ${deletedAt ?? "<NA>"},
ownerId: $ownerId,
type: $type,
data: $data,
isSaved: $isSaved,
memoryAt: $memoryAt,
seenAt: ${seenAt ?? "<NA>"},
showAt: ${showAt ?? "<NA>"},
hideAt: ${hideAt ?? "<NA>"},
assets: $assets
}''';
}
@override
bool operator ==(covariant DriftMemory other) {
if (identical(this, other)) {
return true;
}
final listEquals = const DeepCollectionEquality().equals;
return other.id == id &&
other.createdAt == createdAt &&
other.updatedAt == updatedAt &&
other.deletedAt == deletedAt &&
other.ownerId == ownerId &&
other.type == type &&
other.data == data &&
other.isSaved == isSaved &&
other.memoryAt == memoryAt &&
other.seenAt == seenAt &&
other.showAt == showAt &&
other.hideAt == hideAt &&
listEquals(other.assets, assets);
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
deletedAt.hashCode ^
ownerId.hashCode ^
type.hashCode ^
data.hashCode ^
isSaved.hashCode ^
memoryAt.hashCode ^
seenAt.hashCode ^
showAt.hashCode ^
hideAt.hashCode ^
assets.hashCode;
}
required List<RemoteAsset> assets,
}) = _DriftMemory;
}

View file

@ -1,115 +1,9 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.model.freezed.dart';
// TODO: Remove PersonDto once Isar is removed
class PersonDto {
const PersonDto({
required this.id,
this.birthDate,
required this.isHidden,
required this.name,
required this.thumbnailPath,
this.updatedAt,
});
final String id;
final DateTime? birthDate;
final bool isHidden;
final String name;
final String thumbnailPath;
final DateTime? updatedAt;
@override
String toString() {
return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)';
}
PersonDto copyWith({
String? id,
DateTime? birthDate,
bool? isHidden,
String? name,
String? thumbnailPath,
DateTime? updatedAt,
}) {
return PersonDto(
id: id ?? this.id,
birthDate: birthDate ?? this.birthDate,
isHidden: isHidden ?? this.isHidden,
name: name ?? this.name,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'birthDate': birthDate?.millisecondsSinceEpoch,
'isHidden': isHidden,
'name': name,
'thumbnailPath': thumbnailPath,
'updatedAt': updatedAt?.millisecondsSinceEpoch,
};
}
factory PersonDto.fromMap(Map<String, dynamic> map) {
return PersonDto(
id: map['id'] as String,
birthDate: map['birthDate'] != null ? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int) : null,
isHidden: map['isHidden'] as bool,
name: map['name'] as String,
thumbnailPath: map['thumbnailPath'] as String,
updatedAt: map['updatedAt'] != null ? DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int) : null,
);
}
String toJson() => json.encode(toMap());
factory PersonDto.fromJson(String source) => PersonDto.fromMap(json.decode(source) as Map<String, dynamic>);
@override
bool operator ==(covariant PersonDto other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.birthDate == birthDate &&
other.isHidden == isHidden &&
other.name == name &&
other.thumbnailPath == thumbnailPath &&
other.updatedAt == updatedAt;
}
@override
int get hashCode {
return id.hashCode ^
birthDate.hashCode ^
isHidden.hashCode ^
name.hashCode ^
thumbnailPath.hashCode ^
updatedAt.hashCode;
}
}
// Model for a person stored in the server
@freezed
abstract class DriftPerson with _$DriftPerson {
const factory DriftPerson({
required String id,
required DateTime createdAt,
required DateTime updatedAt,
required String ownerId,
required String name,
String? faceAssetId,
required bool isFavorite,
required bool isHidden,
required String? color,
DateTime? birthDate,
}) = _DriftPerson;
abstract class Person with _$Person {
const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person;
}

View file

@ -1,25 +1,15 @@
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult {
final List<BaseAsset> assets;
final int? nextPage;
part 'search_result.model.freezed.dart';
const SearchResult({required this.assets, this.nextPage});
@Freezed(toStringOverride: false)
abstract class SearchResult with _$SearchResult {
const SearchResult._();
const factory SearchResult({required List<BaseAsset> assets, int? nextPage}) = _SearchResult;
// Explicitly don't log results, only attributes
@override
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) {
return true;
}
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}

View file

@ -1,29 +1,11 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
class Tag {
final String id;
final String value;
part 'tag.model.freezed.dart';
const Tag({required this.id, required this.value});
@override
String toString() {
return 'Tag(id: $id, value: $value)';
}
@override
bool operator ==(covariant Tag other) {
if (identical(this, other)) {
return true;
}
return other.id == id && other.value == value;
}
@override
int get hashCode {
return id.hashCode ^ value.hashCode;
}
@freezed
abstract class Tag with _$Tag {
const factory Tag({required String id, required String value}) = _Tag;
static Tag fromDto(TagResponseDto dto) {
return Tag(id: dto.id, value: dto.value);

View file

@ -1,7 +1,10 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'dart:ui';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.model.freezed.dart';
enum AvatarColor {
// do not change this order or reuse indices for other purposes, adding is OK
primary("primary"),
@ -164,78 +167,15 @@ profileChangedAt: $profileChangedAt
quotaUsageInBytes.hashCode;
}
class PartnerUserDto {
final String id;
final String email;
final String name;
final bool inTimeline;
final String? profileImagePath;
const PartnerUserDto({
required this.id,
required this.email,
required this.name,
required this.inTimeline,
this.profileImagePath,
});
PartnerUserDto copyWith({String? id, String? email, String? name, bool? inTimeline, String? profileImagePath}) {
return PartnerUserDto(
id: id ?? this.id,
email: email ?? this.email,
name: name ?? this.name,
inTimeline: inTimeline ?? this.inTimeline,
profileImagePath: profileImagePath ?? this.profileImagePath,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'email': email,
'name': name,
'inTimeline': inTimeline,
'profileImagePath': profileImagePath,
};
}
factory PartnerUserDto.fromMap(Map<String, dynamic> map) {
return PartnerUserDto(
id: map['id'] as String,
email: map['email'] as String,
name: map['name'] as String,
inTimeline: map['inTimeline'] as bool,
profileImagePath: map['profileImagePath'] != null ? map['profileImagePath'] as String : null,
);
}
String toJson() => json.encode(toMap());
factory PartnerUserDto.fromJson(String source) => PartnerUserDto.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'PartnerUserDto(id: $id, email: $email, name: $name, inTimeline: $inTimeline, profileImagePath: $profileImagePath)';
}
@override
bool operator ==(covariant PartnerUserDto other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.email == email &&
other.name == name &&
other.inTimeline == inTimeline &&
other.profileImagePath == profileImagePath;
}
@override
int get hashCode {
return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode;
}
@freezed
abstract class PartnerUserDto with _$PartnerUserDto {
const factory PartnerUserDto({
required String id,
required String email,
required String name,
required bool inTimeline,
String? profileImagePath,
}) = _PartnerUserDto;
}
class User {

View file

@ -11,105 +11,32 @@ enum UserMetadataKey {
license,
}
class Onboarding {
final bool isOnboarded;
@freezed
abstract class Onboarding with _$Onboarding {
const Onboarding._();
const Onboarding({required this.isOnboarded});
Onboarding copyWith({bool? isOnboarded}) {
return Onboarding(isOnboarded: isOnboarded ?? this.isOnboarded);
}
Map<String, Object?> toMap() {
final onboarding = <String, Object?>{};
onboarding["isOnboarded"] = isOnboarded;
return onboarding;
}
const factory Onboarding({required bool isOnboarded}) = _Onboarding;
factory Onboarding.fromMap(Map<String, Object?> map) {
return Onboarding(isOnboarded: map["isOnboarded"]! as bool);
}
@override
String toString() {
return '''Onboarding {
isOnboarded: $isOnboarded,
}''';
}
@override
bool operator ==(covariant Onboarding other) {
if (identical(this, other)) {
return true;
}
return isOnboarded == other.isOnboarded;
}
@override
int get hashCode => isOnboarded.hashCode;
}
class Preferences {
final bool foldersEnabled;
final bool memoriesEnabled;
final bool peopleEnabled;
final bool ratingsEnabled;
final bool sharedLinksEnabled;
final bool tagsEnabled;
final AvatarColor userAvatarColor;
final bool showSupportBadge;
final int minimumFaces;
@freezed
abstract class Preferences with _$Preferences {
const Preferences._();
const Preferences({
this.foldersEnabled = false,
this.memoriesEnabled = true,
this.peopleEnabled = true,
this.ratingsEnabled = false,
this.sharedLinksEnabled = true,
this.tagsEnabled = false,
this.userAvatarColor = AvatarColor.primary,
this.showSupportBadge = true,
this.minimumFaces = 3,
});
Preferences copyWith({
bool? foldersEnabled,
bool? memoriesEnabled,
bool? peopleEnabled,
bool? ratingsEnabled,
bool? sharedLinksEnabled,
bool? tagsEnabled,
AvatarColor? userAvatarColor,
bool? showSupportBadge,
int? minimumFaces,
}) {
return Preferences(
foldersEnabled: foldersEnabled ?? this.foldersEnabled,
memoriesEnabled: memoriesEnabled ?? this.memoriesEnabled,
peopleEnabled: peopleEnabled ?? this.peopleEnabled,
ratingsEnabled: ratingsEnabled ?? this.ratingsEnabled,
sharedLinksEnabled: sharedLinksEnabled ?? this.sharedLinksEnabled,
tagsEnabled: tagsEnabled ?? this.tagsEnabled,
userAvatarColor: userAvatarColor ?? this.userAvatarColor,
showSupportBadge: showSupportBadge ?? this.showSupportBadge,
minimumFaces: minimumFaces ?? this.minimumFaces,
);
}
Map<String, Object?> toMap() {
final preferences = <String, Object?>{};
preferences["folders-Enabled"] = foldersEnabled;
preferences["memories-Enabled"] = memoriesEnabled;
preferences["people-Enabled"] = peopleEnabled;
preferences["ratings-Enabled"] = ratingsEnabled;
preferences["sharedLinks-Enabled"] = sharedLinksEnabled;
preferences["tags-Enabled"] = tagsEnabled;
preferences["avatar-Color"] = userAvatarColor.value;
preferences["purchase-ShowSupportBadge"] = showSupportBadge;
preferences["minimumFaces"] = minimumFaces;
return preferences;
}
const factory Preferences({
@Default(false) bool foldersEnabled,
@Default(true) bool memoriesEnabled,
@Default(true) bool peopleEnabled,
@Default(false) bool ratingsEnabled,
@Default(true) bool sharedLinksEnabled,
@Default(false) bool tagsEnabled,
@Default(AvatarColor.primary) AvatarColor userAvatarColor,
@Default(true) bool showSupportBadge,
@Default(3) int minimumFaces,
}) = _Preferences;
factory Preferences.fromMap(Map<String, Object?> map) {
return Preferences(
@ -127,75 +54,14 @@ class Preferences {
minimumFaces: (map["people"] as Map<String, Object?>?)?["minimumFaces"] as int? ?? 3,
);
}
@override
String toString() {
return '''Preferences: {
foldersEnabled: $foldersEnabled,
memoriesEnabled: $memoriesEnabled,
peopleEnabled: $peopleEnabled,
ratingsEnabled: $ratingsEnabled,
sharedLinksEnabled: $sharedLinksEnabled,
tagsEnabled: $tagsEnabled,
userAvatarColor: $userAvatarColor,
showSupportBadge: $showSupportBadge,
minimumFaces: $minimumFaces,
}''';
}
@override
bool operator ==(covariant Preferences other) {
if (identical(this, other)) {
return true;
}
return other.foldersEnabled == foldersEnabled &&
other.memoriesEnabled == memoriesEnabled &&
other.peopleEnabled == peopleEnabled &&
other.ratingsEnabled == ratingsEnabled &&
other.sharedLinksEnabled == sharedLinksEnabled &&
other.tagsEnabled == tagsEnabled &&
other.userAvatarColor == userAvatarColor &&
other.showSupportBadge == showSupportBadge &&
other.minimumFaces == minimumFaces;
}
@override
int get hashCode {
return foldersEnabled.hashCode ^
memoriesEnabled.hashCode ^
peopleEnabled.hashCode ^
ratingsEnabled.hashCode ^
sharedLinksEnabled.hashCode ^
tagsEnabled.hashCode ^
userAvatarColor.hashCode ^
showSupportBadge.hashCode ^
minimumFaces.hashCode;
}
}
class License {
final DateTime activatedAt;
final String activationKey;
final String licenseKey;
@freezed
abstract class License with _$License {
const License._();
const License({required this.activatedAt, required this.activationKey, required this.licenseKey});
License copyWith({DateTime? activatedAt, String? activationKey, String? licenseKey}) {
return License(
activatedAt: activatedAt ?? this.activatedAt,
activationKey: activationKey ?? this.activationKey,
licenseKey: licenseKey ?? this.licenseKey,
);
}
Map<String, Object?> toMap() {
final license = <String, Object?>{};
license["activatedAt"] = activatedAt;
license["activationKey"] = activationKey;
license["licenseKey"] = licenseKey;
return license;
}
const factory License({required DateTime activatedAt, required String activationKey, required String licenseKey}) =
_License;
factory License.fromMap(Map<String, Object?> map) {
return License(
@ -204,27 +70,6 @@ class License {
licenseKey: map["licenseKey"]! as String,
);
}
@override
String toString() {
return '''License {
activatedAt: $activatedAt,
activationKey: $activationKey,
licenseKey: $licenseKey,
}''';
}
@override
bool operator ==(covariant License other) {
if (identical(this, other)) {
return true;
}
return activatedAt == other.activatedAt && activationKey == other.activationKey && licenseKey == other.licenseKey;
}
@override
int get hashCode => activatedAt.hashCode ^ activationKey.hashCode ^ licenseKey.hashCode;
}
// Model for a user metadata stored in the server

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

@ -50,16 +50,39 @@ class BackgroundSyncManager {
this.onCloudIdSyncError,
});
// The tasks the app-resume path re-runs. One in-flight when the app was suspended
// stays referenced but frozen, so on resume the dedupe guards would hand back the
// stale task instead of syncing (#28082). Websocket and cloud-id are excluded - the
// resume path never restarts them. [_allTasks] builds on this so the lists can't drift.
List<Cancelable?> get _resumeSyncTasks => [_syncTask, _deviceAlbumSyncTask, _hashTask, _linkedAlbumSyncTask];
List<Cancelable?> get _allTasks => [_syncWebsocketTask, _cloudIdSyncTask, ..._resumeSyncTasks];
Future<void> cancel() async {
_syncQueued = false;
final tasks = [
_syncTask,
_syncWebsocketTask,
_cloudIdSyncTask,
_linkedAlbumSyncTask,
_deviceAlbumSyncTask,
_hashTask,
];
final tasks = _allTasks;
_syncTask = null;
_syncWebsocketTask = null;
_cloudIdSyncTask = null;
_linkedAlbumSyncTask = null;
_deviceAlbumSyncTask = null;
_hashTask = null;
await _cancelAll(tasks);
}
Future<void> cancelResumeSyncs() async {
_syncQueued = false;
final tasks = _resumeSyncTasks;
_syncTask = null;
_deviceAlbumSyncTask = null;
_hashTask = null;
_linkedAlbumSyncTask = null;
await _cancelAll(tasks);
}
// Cancels every task in [tasks] and waits for them to unwind. Callers null out
// their own fields first, so the sync guards see a clean slate immediately.
Future<void> _cancelAll(List<Cancelable?> tasks) async {
final futures = [
for (final task in tasks)
if (task != null) task.future,
@ -67,13 +90,6 @@ class BackgroundSyncManager {
for (final task in tasks) {
task?.cancel();
}
_syncTask = null;
_syncWebsocketTask = null;
_cloudIdSyncTask = null;
_linkedAlbumSyncTask = null;
_deviceAlbumSyncTask = null;
_hashTask = null;
try {
await Future.wait(futures);
} on CanceledError {
@ -84,14 +100,14 @@ class BackgroundSyncManager {
// No need to cancel the task, as it can also be run when the user logs out
Future<void> syncLocal({bool full = false}) {
if (_deviceAlbumSyncTask != null) {
return _deviceAlbumSyncTask!.future;
return _deviceAlbumSyncTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
}
onLocalSyncStart?.call();
// We use a ternary operator to avoid [_deviceAlbumSyncTask] from being
// captured by the closure passed to [runInIsolateGentle].
_deviceAlbumSyncTask = full
final task = _deviceAlbumSyncTask = full
? runInIsolateGentle(
computation: (ref) => ref.read(localSyncServiceProvider).sync(full: true),
debugLabel: 'local-sync-full-true',
@ -101,37 +117,43 @@ class BackgroundSyncManager {
debugLabel: 'local-sync-full-false',
);
return _deviceAlbumSyncTask!
return task
.whenComplete(() {
_deviceAlbumSyncTask = null;
if (identical(_deviceAlbumSyncTask, task)) {
_deviceAlbumSyncTask = null;
}
onLocalSyncComplete?.call();
})
.catchError((error) {
onLocalSyncError?.call(error.toString());
_deviceAlbumSyncTask = null;
if (error is! CanceledError) {
onLocalSyncError?.call(error.toString());
}
});
}
Future<void> hashAssets() {
if (_hashTask != null) {
return _hashTask!.future;
return _hashTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
}
onHashingStart?.call();
_hashTask = runInIsolateGentle(
final task = _hashTask = runInIsolateGentle(
computation: (ref) => ref.read(hashServiceProvider).hashAssets(),
debugLabel: 'hash-assets',
);
return _hashTask!
return task
.whenComplete(() {
onHashingComplete?.call();
_hashTask = null;
if (identical(_hashTask, task)) {
_hashTask = null;
}
})
.catchError((error) {
onHashingError?.call(error.toString());
_hashTask = null;
if (error is! CanceledError) {
onHashingError?.call(error.toString());
}
});
}
@ -143,11 +165,11 @@ class BackgroundSyncManager {
onRemoteSyncStart?.call();
_syncTask = runInIsolateGentle(
final task = _syncTask = runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
debugLabel: 'remote-sync',
);
return _syncTask!
return task
.then((result) {
final success = result ?? false;
onRemoteSyncComplete?.call(success);
@ -155,15 +177,21 @@ class BackgroundSyncManager {
return success;
})
.catchError((error) {
onRemoteSyncError?.call(error.toString());
if (error is! CanceledError) {
onRemoteSyncError?.call(error.toString());
}
_syncQueued = false;
return false;
})
// A task clears only its own slot: one that was cancelled and superseded by a
// fresh task (see cancelResumeSyncs) must not null the new task's slot.
.whenComplete(() {
_syncTask = null;
if (_syncQueued) {
_syncQueued = false;
unawaited(syncRemote());
if (identical(_syncTask, task)) {
_syncTask = null;
if (_syncQueued) {
_syncQueued = false;
unawaited(syncRemote());
}
}
});
}
@ -210,13 +238,21 @@ class BackgroundSyncManager {
Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future;
return _linkedAlbumSyncTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
}
_linkedAlbumSyncTask = runInIsolateGentle(computation: syncLinkedAlbumsIsolated, debugLabel: 'linked-album-sync');
return _linkedAlbumSyncTask!.whenComplete(() {
_linkedAlbumSyncTask = null;
});
final task = _linkedAlbumSyncTask = runInIsolateGentle(
computation: syncLinkedAlbumsIsolated,
debugLabel: 'linked-album-sync',
);
return task
.whenComplete(() {
if (identical(_linkedAlbumSyncTask, task)) {
_linkedAlbumSyncTask = null;
}
})
// a cancelled resume sync is not a failure; absorb it so the websocket callers don't get an uncaught error
.catchError((_) {}, test: (error) => error is CanceledError);
}
Future<void> syncCloudIds() {

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

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:ffi/ffi.dart';
@ -36,7 +37,11 @@ abstract class ImageRequest {
void _onCancelled();
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(
int address,
int length, {
ui.Size? decodeSize,
}) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
@ -62,7 +67,8 @@ abstract class ImageRequest {
return null;
}
final codec = await descriptor.instantiateCodec();
final target = _targetSize(descriptor.width, descriptor.height, decodeSize);
final codec = await descriptor.instantiateCodec(targetWidth: target?.$1, targetHeight: target?.$2);
if (_isCancelled) {
descriptor.dispose();
codec.dispose();
@ -72,8 +78,8 @@ abstract class ImageRequest {
return (codec, descriptor);
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final result = await _codecFromEncodedPlatformImage(address, length);
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async {
final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize);
if (result == null) {
return null;
}
@ -96,6 +102,19 @@ abstract class ImageRequest {
return frame;
}
(int, int)? _targetSize(int width, int height, ui.Size? decodeSize) {
if (width <= 0 || height <= 0 || decodeSize == null || decodeSize.width <= 0 || decodeSize.height <= 0) {
return null;
}
final scale = math.max(decodeSize.width / width, decodeSize.height / height);
if (scale >= 1) {
return null;
}
return ((width * scale).ceil(), (height * scale).ceil());
}
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {

View file

@ -3,7 +3,10 @@ part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
final String uri;
RemoteImageRequest({required this.uri});
/// Physical size to decode, or null for the source size.
final ui.Size? decodeSize;
RemoteImageRequest({required this.uri, this.decodeSize});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@ -11,10 +14,20 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
final info = await remoteImageApi.requestImage(
uri,
requestId: requestId,
preferEncoded: false,
width: decodeSize?.width.ceil(),
height: decodeSize?.height.ceil(),
);
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
final frame = switch (info) {
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(
pointer,
length,
decodeSize: decodeSize,
),
{
'pointer': final int pointer,
'width': final int width,
@ -33,7 +46,13 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
final info = await remoteImageApi.requestImage(
uri,
requestId: requestId,
preferEncoded: true,
width: null,
height: null,
);
if (info == null) {
return null;
}

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

@ -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,34 +1,14 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
class AuxilaryEndpoint {
final String url;
final AuxCheckStatus status;
import 'package:freezed_annotation/freezed_annotation.dart';
const AuxilaryEndpoint({required this.url, required this.status});
part 'auxilary_endpoint.model.freezed.dart';
AuxilaryEndpoint copyWith({String? url, AuxCheckStatus? status}) {
return AuxilaryEndpoint(url: url ?? this.url, status: status ?? this.status);
}
@Freezed(fromJson: false, toJson: false)
abstract class AuxilaryEndpoint with _$AuxilaryEndpoint {
const AuxilaryEndpoint._();
@override
String toString() => 'AuxilaryEndpoint(url: $url, status: $status)';
@override
bool operator ==(covariant AuxilaryEndpoint other) {
if (identical(this, other)) {
return true;
}
return other.url == url && other.status == status;
}
@override
int get hashCode => url.hashCode ^ status.hashCode;
Map<String, dynamic> toMap() {
return <String, dynamic>{'url': url, 'status': status.toMap()};
}
const factory AuxilaryEndpoint({required String url, required AuxCheckStatus status}) = _AuxilaryEndpoint;
factory AuxilaryEndpoint.fromMap(Map<String, dynamic> map) {
return AuxilaryEndpoint(
@ -37,50 +17,23 @@ class AuxilaryEndpoint {
);
}
String toJson() => json.encode(toMap());
factory AuxilaryEndpoint.fromJson(String source) =>
AuxilaryEndpoint.fromMap(json.decode(source) as Map<String, dynamic>);
}
class AuxCheckStatus {
final String name;
const AuxCheckStatus({required this.name});
const AuxCheckStatus._(this.name);
// TODO(agg23): Should be an enum
@freezed
abstract class AuxCheckStatus with _$AuxCheckStatus {
const AuxCheckStatus._();
static const loading = AuxCheckStatus._('loading');
static const valid = AuxCheckStatus._('valid');
static const error = AuxCheckStatus._('error');
static const unknown = AuxCheckStatus._('unknown');
const factory AuxCheckStatus({required String name}) = _AuxCheckStatus;
@override
bool operator ==(covariant AuxCheckStatus other) {
if (identical(this, other)) {
return true;
}
return other.name == name;
}
@override
int get hashCode => name.hashCode;
AuxCheckStatus copyWith({String? name}) {
return AuxCheckStatus(name: name ?? this.name);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{'name': name};
}
static const loading = AuxCheckStatus(name: 'loading');
static const valid = AuxCheckStatus(name: 'valid');
static const error = AuxCheckStatus(name: 'error');
static const unknown = AuxCheckStatus(name: 'unknown');
factory AuxCheckStatus.fromMap(Map<String, dynamic> map) {
return AuxCheckStatus(name: map['name'] as String);
}
String toJson() => json.encode(toMap());
factory AuxCheckStatus.fromJson(String source) => AuxCheckStatus.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'AuxCheckStatus(name: $name)';
}

View file

@ -1,32 +1,10 @@
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:local_auth/local_auth.dart';
class BiometricStatus {
final List<BiometricType> availableBiometrics;
final bool canAuthenticate;
part 'biometric_status.model.freezed.dart';
const BiometricStatus({required this.availableBiometrics, required this.canAuthenticate});
@override
String toString() => 'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)';
BiometricStatus copyWith({List<BiometricType>? availableBiometrics, bool? canAuthenticate}) {
return BiometricStatus(
availableBiometrics: availableBiometrics ?? this.availableBiometrics,
canAuthenticate: canAuthenticate ?? this.canAuthenticate,
);
}
@override
bool operator ==(covariant BiometricStatus other) {
if (identical(this, other)) {
return true;
}
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.availableBiometrics, availableBiometrics) && other.canAuthenticate == canAuthenticate;
}
@override
int get hashCode => availableBiometrics.hashCode ^ canAuthenticate.hashCode;
@freezed
abstract class BiometricStatus with _$BiometricStatus {
const factory BiometricStatus({required List<BiometricType> availableBiometrics, required bool canAuthenticate}) =
_BiometricStatus;
}

View file

@ -1,85 +1,18 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'cast_manager_state.freezed.dart';
enum CastDestinationType { googleCast }
enum CastState { idle, playing, paused, buffering }
class CastManagerState {
final bool isCasting;
final String receiverName;
final CastState castState;
final Duration currentTime;
final Duration duration;
const CastManagerState({
required this.isCasting,
required this.receiverName,
required this.castState,
required this.currentTime,
required this.duration,
});
CastManagerState copyWith({
bool? isCasting,
String? receiverName,
CastState? castState,
Duration? currentTime,
Duration? duration,
}) {
return CastManagerState(
isCasting: isCasting ?? this.isCasting,
receiverName: receiverName ?? this.receiverName,
castState: castState ?? this.castState,
currentTime: currentTime ?? this.currentTime,
duration: duration ?? this.duration,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'isCasting': isCasting});
result.addAll({'receiverName': receiverName});
result.addAll({'castState': castState});
result.addAll({'currentTime': currentTime.inSeconds});
result.addAll({'duration': duration.inSeconds});
return result;
}
factory CastManagerState.fromMap(Map<String, dynamic> map) {
return CastManagerState(
isCasting: map['isCasting'] ?? false,
receiverName: map['receiverName'] ?? '',
castState: map['castState'] ?? CastState.idle,
currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0),
duration: Duration(seconds: map['duration']?.toInt() ?? 0),
);
}
String toJson() => json.encode(toMap());
factory CastManagerState.fromJson(String source) => CastManagerState.fromMap(json.decode(source));
@override
String toString() =>
'CastManagerState(isCasting: $isCasting, receiverName: $receiverName, castState: $castState, currentTime: $currentTime, duration: $duration)';
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is CastManagerState &&
other.isCasting == isCasting &&
other.receiverName == receiverName &&
other.castState == castState &&
other.currentTime == currentTime &&
other.duration == duration;
}
@override
int get hashCode =>
isCasting.hashCode ^ receiverName.hashCode ^ castState.hashCode ^ currentTime.hashCode ^ duration.hashCode;
@freezed
abstract class CastManagerState with _$CastManagerState {
const factory CastManagerState({
required bool isCasting,
required String receiverName,
required CastState castState,
required Duration currentTime,
required Duration duration,
}) = _CastManagerState;
}

View file

@ -1,88 +1,20 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
class DownloadInfo {
final String fileName;
final double progress;
// enum
final TaskStatus status;
const DownloadInfo({required this.fileName, required this.progress, required this.status});
DownloadInfo copyWith({String? fileName, double? progress, TaskStatus? status}) {
return DownloadInfo(
fileName: fileName ?? this.fileName,
progress: progress ?? this.progress,
status: status ?? this.status,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{'fileName': fileName, 'progress': progress, 'status': status.index};
}
factory DownloadInfo.fromMap(Map<String, dynamic> map) {
return DownloadInfo(
fileName: map['fileName'] as String,
progress: map['progress'] as double,
status: TaskStatus.values[map['status'] as int],
);
}
String toJson() => json.encode(toMap());
factory DownloadInfo.fromJson(String source) => DownloadInfo.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)';
@override
bool operator ==(covariant DownloadInfo other) {
if (identical(this, other)) {
return true;
}
return other.fileName == fileName && other.progress == progress && other.status == status;
}
@override
int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode;
}
class DownloadState {
// enum
final TaskStatus downloadStatus;
final Map<String, DownloadInfo> taskProgress;
final bool showProgress;
const DownloadState({required this.downloadStatus, required this.taskProgress, required this.showProgress});
DownloadState copyWith({TaskStatus? downloadStatus, Map<String, DownloadInfo>? taskProgress, bool? showProgress}) {
return DownloadState(
downloadStatus: downloadStatus ?? this.downloadStatus,
taskProgress: taskProgress ?? this.taskProgress,
showProgress: showProgress ?? this.showProgress,
);
}
@override
String toString() =>
'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)';
@override
bool operator ==(covariant DownloadState other) {
if (identical(this, other)) {
return true;
}
final mapEquals = const DeepCollectionEquality().equals;
return other.downloadStatus == downloadStatus &&
mapEquals(other.taskProgress, taskProgress) &&
other.showProgress == showProgress;
}
@override
int get hashCode => downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode;
}
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:background_downloader/background_downloader.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'download_state.model.freezed.dart';
@freezed
abstract class DownloadInfo with _$DownloadInfo {
const factory DownloadInfo({required String fileName, required double progress, required TaskStatus status}) =
_DownloadInfo;
}
@freezed
abstract class DownloadState with _$DownloadState {
const factory DownloadState({
required TaskStatus downloadStatus,
required Map<String, DownloadInfo> taskProgress,
required bool showProgress,
}) = _DownloadState;
}

View file

@ -1,54 +1,20 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
/// A wrapper for [CuratedLocationsResponseDto] objects
/// and [CuratedObjectsResponseDto] to be displayed in
/// a view
class SearchCuratedContent {
/// The label to show associated with this curated object
final String label;
/// The subtitle to show below the label
final String? subtitle;
/// The id to lookup the asset from the server
final String id;
const SearchCuratedContent({required this.label, required this.id, this.subtitle});
SearchCuratedContent copyWith({String? label, String? subtitle, String? id}) {
return SearchCuratedContent(label: label ?? this.label, subtitle: subtitle ?? this.subtitle, id: id ?? this.id);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{'label': label, 'subtitle': subtitle, 'id': id};
}
factory SearchCuratedContent.fromMap(Map<String, dynamic> map) {
return SearchCuratedContent(
label: map['label'] as String,
subtitle: map['subtitle'] as String?,
id: map['id'] as String,
);
}
String toJson() => json.encode(toMap());
factory SearchCuratedContent.fromJson(String source) =>
SearchCuratedContent.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'CuratedContent(label: $label, subtitle: $subtitle, id: $id)';
@override
bool operator ==(covariant SearchCuratedContent other) {
if (identical(this, other)) {
return true;
}
return other.label == label && other.subtitle == subtitle && other.id == id;
}
@override
int get hashCode => label.hashCode ^ id.hashCode;
}
import 'package:freezed_annotation/freezed_annotation.dart';
part 'search_curated_content.model.freezed.dart';
/// A wrapper for [CuratedLocationsResponseDto] objects
/// and [CuratedObjectsResponseDto] to be displayed in
/// a view
@freezed
abstract class SearchCuratedContent with _$SearchCuratedContent {
const factory SearchCuratedContent({
/// The label to show associated with this curated object
required String label,
/// The id to lookup the asset from the server
required String id,
/// The subtitle to show below the label
String? subtitle,
}) = _SearchCuratedContent;
}

View file

@ -1,10 +1,13 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/utils/option.dart';
part 'search_filter.model.freezed.dart';
class SearchLocationFilter {
String? country;
String? state;
@ -177,52 +180,10 @@ class SearchRatingFilter {
int get hashCode => rating.hashCode;
}
class SearchDisplayFilters {
bool isNotInAlbum = false;
bool isArchive = false;
bool isFavorite = false;
SearchDisplayFilters({required this.isNotInAlbum, required this.isArchive, required this.isFavorite});
SearchDisplayFilters copyWith({bool? isNotInAlbum, bool? isArchive, bool? isFavorite}) {
return SearchDisplayFilters(
isNotInAlbum: isNotInAlbum ?? this.isNotInAlbum,
isArchive: isArchive ?? this.isArchive,
isFavorite: isFavorite ?? this.isFavorite,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{'isNotInAlbum': isNotInAlbum, 'isArchive': isArchive, 'isFavorite': isFavorite};
}
factory SearchDisplayFilters.fromMap(Map<String, dynamic> map) {
return SearchDisplayFilters(
isNotInAlbum: map['isNotInAlbum'] as bool,
isArchive: map['isArchive'] as bool,
isFavorite: map['isFavorite'] as bool,
);
}
String toJson() => json.encode(toMap());
factory SearchDisplayFilters.fromJson(String source) =>
SearchDisplayFilters.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() =>
'SearchDisplayFilters(isNotInAlbum: $isNotInAlbum, isArchive: $isArchive, isFavorite: $isFavorite)';
@override
bool operator ==(covariant SearchDisplayFilters other) {
if (identical(this, other)) {
return true;
}
return other.isNotInAlbum == isNotInAlbum && other.isArchive == isArchive && other.isFavorite == isFavorite;
}
@override
int get hashCode => isNotInAlbum.hashCode ^ isArchive.hashCode ^ isFavorite.hashCode;
@freezed
abstract class SearchDisplayFilters with _$SearchDisplayFilters {
const factory SearchDisplayFilters({required bool isNotInAlbum, required bool isArchive, required bool isFavorite}) =
_SearchDisplayFilters;
}
class SearchFilter {
@ -233,7 +194,7 @@ class SearchFilter {
String? language;
String? assetId;
List<String>? tagIds;
Set<PersonDto> people;
Set<Person> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
@ -289,7 +250,7 @@ class SearchFilter {
String? language,
String? ocr,
String? assetId,
Set<PersonDto>? people,
Set<Person>? people,
List<String>? tagIds,
SearchLocationFilter? location,
SearchCameraFilter? camera,

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

@ -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,7 +128,7 @@ class HeaderKeyValueSettings extends StatelessWidget {
child: TextFormField(
controller: keyController,
decoration: InputDecoration(
labelText: 'header_settings_header_name_input'.tr(),
labelText: context.t.header_settings_header_name_input,
border: const OutlineInputBorder(),
),
autocorrect: false,
@ -157,7 +156,7 @@ class HeaderKeyValueSettings extends StatelessWidget {
child: TextFormField(
controller: valueController,
decoration: InputDecoration(
labelText: 'header_settings_header_value_input'.tr(),
labelText: context.t.header_settings_header_value_input,
border: const OutlineInputBorder(),
),
autocorrect: false,

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

@ -31,7 +31,7 @@ class SimilarPhotosAction extends ActionBuilder {
location: .new(),
camera: .new(),
date: .new(),
display: .new(isNotInAlbum: false, isArchive: false, isFavorite: false),
display: const .new(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: .new(),
mediaType: .other,
),

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,7 +56,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('local_media_summary'.tr())),
appBar: AppBar(title: Text(context.t.local_media_summary)),
body: Consumer(
builder: (ctx, ref, __) {
final db = ref.watch(driftProvider);
@ -79,7 +79,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
child: Text(ctx.t.album_summary, style: ctx.textTheme.titleMedium),
),
],
),
@ -136,7 +136,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('remote_media_summary'.tr())),
appBar: AppBar(title: Text(context.t.remote_media_summary)),
body: Consumer(
builder: (ctx, ref, __) {
final db = ref.watch(driftProvider);
@ -159,7 +159,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
child: Text(ctx.t.album_summary, style: ctx.textTheme.titleMedium),
),
],
),

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,11 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@ -33,7 +33,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
@override
Widget build(BuildContext context) {
final people = ref.watch(driftGetAllPeopleProvider);
final people = ref.watch(getAllPeopleProvider);
return LayoutBuilder(
builder: (context, constraints) {
@ -49,10 +49,10 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
onTapOutside: (_) => _formFocus.unfocus(),
onChanged: (value) => setState(() => _search = value),
filled: true,
hintText: 'filter_people'.tr(),
hintText: context.t.filter_people,
autofocus: true,
)
: Text('people'.tr()),
: Text(context.t.people),
actions: [
IconButton(
icon: Icon(_search != null ? Icons.close : Icons.search),
@ -107,7 +107,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
onTap: () => showNameEditModal(context, person),
child: person.name.isEmpty
? Text(
'add_a_name'.tr(),
context.t.add_a_name,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.primary,

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

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

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