mirror of
https://github.com/immich-app/immich.git
synced 2026-03-03 15:07:00 +00:00
Compare commits
1 Commits
feat/crawl
...
postgres-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f276b543 |
2
.github/.nvmrc
vendored
2
.github/.nvmrc
vendored
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.13.0
|
||||
|
||||
16
.github/workflows/build-mobile.yml
vendored
16
.github/workflows/build-mobile.yml
vendored
@@ -51,14 +51,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -79,12 +79,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
@@ -96,14 +96,14 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Restore Gradle Cache
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
- name: Save Gradle Cache
|
||||
id: cache-gradle-save
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: |
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/cache-cleanup.yml
vendored
4
.github/workflows/cache-cleanup.yml
vendored
@@ -19,13 +19,13 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
32
.github/workflows/check-openapi.yml
vendored
32
.github/workflows/check-openapi.yml
vendored
@@ -1,32 +0,0 @@
|
||||
name: Check OpenAPI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'open-api/**'
|
||||
- '.github/workflows/check-openapi.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-openapi:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
||||
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
fail-on: ERR
|
||||
14
.github/workflows/cli.yml
vendored
14
.github/workflows/cli.yml
vendored
@@ -31,12 +31,12 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@@ -71,13 +71,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
file: cli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
4
.github/workflows/close-llm-pr.yml
vendored
4
.github/workflows/close-llm-pr.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Close LLM-generated PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our CONTRIBUTING.md, we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
|
||||
10
.github/workflows/codeql-analysis.yml
vendored
10
.github/workflows/codeql-analysis.yml
vendored
@@ -44,20 +44,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
|
||||
# ℹ️ 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
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
12
.github/workflows/docker.yml
vendored
12
.github/workflows/docker.yml
vendored
@@ -23,14 +23,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
suffix: ['']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -155,7 +155,7 @@ 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@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
10
.github/workflows/docs-build.yml
vendored
10
.github/workflows/docs-build.yml
vendored
@@ -21,14 +21,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -54,13 +54,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
25
.github/workflows/docs-deploy.yml
vendored
25
.github/workflows/docs-deploy.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
artifact: ${{ steps.get-artifact.outputs.result }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -119,19 +119,19 @@ jobs:
|
||||
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||
|
||||
- name: Load parameters
|
||||
id: parameters
|
||||
@@ -192,13 +192,16 @@ jobs:
|
||||
' >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Publish to Cloudflare Pages
|
||||
working-directory: docs
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
PROJECT_NAME: ${{ steps.docs-output.outputs.projectName }}
|
||||
BRANCH_NAME: ${{ steps.parameters.outputs.name }}
|
||||
run: mise run //docs:deploy
|
||||
# TODO: Action is deprecated
|
||||
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ steps.docs-output.outputs.projectName }}
|
||||
workingDirectory: 'docs'
|
||||
directory: 'build'
|
||||
branch: ${{ steps.parameters.outputs.name }}
|
||||
wranglerVersion: '3'
|
||||
|
||||
- name: Deploy Docs Release Domain
|
||||
if: ${{ steps.parameters.outputs.event == 'release' }}
|
||||
|
||||
6
.github/workflows/docs-destroy.yml
vendored
6
.github/workflows/docs-destroy.yml
vendored
@@ -17,19 +17,19 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||
|
||||
- name: Destroy Docs Subdomain
|
||||
env:
|
||||
|
||||
6
.github/workflows/fix-format.yml
vendored
6
.github/workflows/fix-format.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
@@ -32,14 +32,14 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Fix formatting
|
||||
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
|
||||
run: pnpm --recursive install && pnpm run --recursive --parallel fix:format
|
||||
|
||||
- name: Commit and push
|
||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||
|
||||
2
.github/workflows/pr-label-validation.yml
vendored
2
.github/workflows/pr-label-validation.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
8
.github/workflows/prepare-release.yml
vendored
8
.github/workflows/prepare-release.yml
vendored
@@ -56,20 +56,20 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/preview-label.yaml
vendored
4
.github/workflows/preview-label.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
8
.github/workflows/release-pr.yml
vendored
8
.github/workflows/release-pr.yml
vendored
@@ -23,20 +23,20 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Create PR
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
@@ -88,7 +88,6 @@ jobs:
|
||||
draft: true
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
|
||||
6
.github/workflows/sdk.yml
vendored
6
.github/workflows/sdk.yml
vendored
@@ -19,12 +19,12 @@ jobs:
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
16
.github/workflows/static_analysis.yml
vendored
16
.github/workflows/static_analysis.yml
vendored
@@ -20,14 +20,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -69,14 +69,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
|
||||
- name: Install dependencies for UI package
|
||||
run: dart pub get
|
||||
working-directory: ./mobile/packages/ui
|
||||
|
||||
- name: Install dependencies for UI Showcase
|
||||
run: dart pub get
|
||||
working-directory: ./mobile/packages/ui/showcase
|
||||
|
||||
- name: Install DCM
|
||||
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
|
||||
with:
|
||||
|
||||
128
.github/workflows/test.yml
vendored
128
.github/workflows/test.yml
vendored
@@ -17,14 +17,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -63,13 +63,13 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -108,20 +108,20 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -155,20 +155,20 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -197,20 +197,20 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -241,20 +241,20 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -279,20 +279,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -327,20 +327,20 @@ jobs:
|
||||
working-directory: ./e2e
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -373,13 +373,13 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -387,7 +387,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -412,13 +412,13 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -446,29 +446,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Start Docker Compose
|
||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
- name: Docker build
|
||||
run: docker compose build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (api & cli)
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (maintenance)
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test:maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
working-directory: ./e2e
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-server-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
e2e-tests-web:
|
||||
name: End-to-End Tests (Web)
|
||||
needs: pre-job
|
||||
@@ -484,13 +467,13 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -498,7 +481,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -518,8 +501,9 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (web)
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web
|
||||
run: npx playwright test --project=web
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive e2e test (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
@@ -529,8 +513,9 @@ jobs:
|
||||
path: e2e/playwright-report/
|
||||
- name: Run ui tests (web)
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web:ui
|
||||
run: npx playwright test --project=ui
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive ui test (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
@@ -540,8 +525,9 @@ jobs:
|
||||
path: e2e/playwright-report/
|
||||
- name: Run maintenance tests
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web:maintenance
|
||||
run: npx playwright test --project=maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive maintenance tests (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
@@ -557,7 +543,7 @@ jobs:
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-web-docker-logs-${{ matrix.runner }}
|
||||
name: docker-compose-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
success-check-e2e:
|
||||
name: End-to-End Tests Success
|
||||
@@ -578,12 +564,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -610,17 +596,17 @@ jobs:
|
||||
working-directory: ./machine-learning
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install dependencies
|
||||
@@ -650,20 +636,20 @@ jobs:
|
||||
working-directory: ./.github
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './.github/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -680,12 +666,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -701,20 +687,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -763,20 +749,20 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
6
.github/workflows/weblate-lock.yml
vendored
6
.github/workflows/weblate-lock.yml
vendored
@@ -24,14 +24,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.13.0
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@types/byte-size": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.11",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -69,6 +69,6 @@
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,65 +14,33 @@
|
||||
name: immich-dev
|
||||
|
||||
services:
|
||||
immich-app-base:
|
||||
profiles: ['_base']
|
||||
tmpfs:
|
||||
- /tmp
|
||||
volumes:
|
||||
- ..:/usr/src/app
|
||||
- pnpm_cache:/buildcache/pnpm_cache
|
||||
- server_node_modules:/usr/src/app/server/node_modules
|
||||
- web_node_modules:/usr/src/app/web/node_modules
|
||||
- github_node_modules:/usr/src/app/.github/node_modules
|
||||
- cli_node_modules:/usr/src/app/cli/node_modules
|
||||
- docs_node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e_node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk_node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app_node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
|
||||
immich-init:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
container_name: immich_init
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile.dev
|
||||
target: dev
|
||||
command:
|
||||
- |
|
||||
pnpm install
|
||||
touch /tmp/init-complete
|
||||
exec tail -f /dev/null
|
||||
volumes:
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
restart: 'no'
|
||||
healthcheck:
|
||||
test: ['CMD', 'test', '-f', '/tmp/init-complete']
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 300
|
||||
start_period: 300s
|
||||
|
||||
immich-server:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
container_name: immich_server
|
||||
command: ['immich-dev']
|
||||
image: immich-server-dev:latest
|
||||
# extends:
|
||||
# file: hwaccel.transcoding.yml
|
||||
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile.dev
|
||||
target: dev
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ..:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- ../plugins:/build/corePlugin
|
||||
env_file:
|
||||
- .env
|
||||
@@ -95,8 +63,6 @@ services:
|
||||
- 9231:9231
|
||||
- 2283:2283
|
||||
depends_on:
|
||||
immich-init:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
database:
|
||||
@@ -105,9 +71,6 @@ services:
|
||||
disable: false
|
||||
|
||||
immich-web:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
container_name: immich_web
|
||||
image: immich-web-dev:latest
|
||||
build:
|
||||
@@ -121,11 +84,20 @@ services:
|
||||
- 3000:3000
|
||||
- 24678:24678
|
||||
volumes:
|
||||
- pnpm_store_web:/buildcache/pnpm-store
|
||||
- ..:/usr/src/app
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
immich-init:
|
||||
condition: service_healthy
|
||||
immich-server:
|
||||
condition: service_started
|
||||
|
||||
@@ -144,7 +116,7 @@ services:
|
||||
- 3003:3003
|
||||
volumes:
|
||||
- ../machine-learning/immich_ml:/usr/src/immich_ml
|
||||
- model_cache:/cache
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
@@ -184,7 +156,7 @@ services:
|
||||
# image: prom/prometheus
|
||||
# volumes:
|
||||
# - ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
# - prometheus_data:/prometheus
|
||||
# - prometheus-data:/prometheus
|
||||
|
||||
# first login uses admin/admin
|
||||
# add data source for http://immich-prometheus:9090 to get started
|
||||
@@ -195,22 +167,20 @@ services:
|
||||
# - 3000:3000
|
||||
# image: grafana/grafana:10.3.3-ubuntu
|
||||
# volumes:
|
||||
# - grafana_data:/var/lib/grafana
|
||||
# - grafana-data:/var/lib/grafana
|
||||
|
||||
volumes:
|
||||
model_cache:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
pnpm_cache:
|
||||
pnpm_store_server:
|
||||
pnpm_store_web:
|
||||
server_node_modules:
|
||||
web_node_modules:
|
||||
github_node_modules:
|
||||
cli_node_modules:
|
||||
docs_node_modules:
|
||||
e2e_node_modules:
|
||||
sdk_node_modules:
|
||||
app_node_modules:
|
||||
model-cache:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
pnpm-store:
|
||||
server-node_modules:
|
||||
web-node_modules:
|
||||
github-node_modules:
|
||||
cli-node_modules:
|
||||
docs-node_modules:
|
||||
e2e-node_modules:
|
||||
sdk-node_modules:
|
||||
app-node_modules:
|
||||
sveltekit:
|
||||
coverage:
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.13.0
|
||||
|
||||
@@ -44,7 +44,7 @@ While this guide focuses on VS Code, you have many options for Dev Container dev
|
||||
**Self-Hostable Options:**
|
||||
|
||||
- [Coder](https://coder.com) - Enterprise-focused, requires Terraform knowledge, self-managed
|
||||
- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise). Check [quick-start guide](#quick-start-guide-for-devpod-with-docker)
|
||||
- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise)
|
||||
:::
|
||||
|
||||
## Dev Container Services
|
||||
@@ -408,27 +408,7 @@ If you encounter issues:
|
||||
1. Check container logs: View → Output → Select "Dev Containers"
|
||||
2. Rebuild without cache: "Dev Containers: Rebuild Container Without Cache"
|
||||
3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/)
|
||||
4. Ask in [Discord](https://discord.immich.app) `#contributing` channel
|
||||
|
||||
### Quick-start guide for DevPod with docker
|
||||
|
||||
You will need DevPod CLI (check [DevPod CLI installation guide](https://devpod.sh/docs/getting-started/install)) and Docker Desktop.
|
||||
|
||||
```sh
|
||||
# Step 1: Clone the Repository
|
||||
git clone https://github.com/immich-app/immich.git
|
||||
cd immich
|
||||
|
||||
# Step 2: Prepare DevPod (if you haven't already)
|
||||
devpod provider add docker
|
||||
devpod provider use docker
|
||||
|
||||
# Step 3: Build 'immich-server-dev' docker image first manually
|
||||
docker build -f server/Dockerfile.dev -t immich-server-dev .
|
||||
|
||||
# Step 4: Now you can start devcontainer
|
||||
devpod up .
|
||||
```
|
||||
4. Ask in [Discord](https://discord.immich.app) `#help-desk-support` channel
|
||||
|
||||
## Mobile Development
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
|
||||
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
|
||||
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
||||
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
||||
| `MXF` | `.mxf` | :white_check_mark: | |
|
||||
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
||||
| `WEBM` | `.webm` | :white_check_mark: | |
|
||||
| `WMV` | `.wmv` | :white_check_mark: | |
|
||||
|
||||
@@ -8,8 +8,7 @@ A config file can be provided as an alternative to the UI configuration.
|
||||
|
||||
### Step 1 - Create a new config file
|
||||
|
||||
In JSON format, create a new config file (e.g. `immich.json`) and put it in a location mounted in the container that can be accessed by Immich.
|
||||
YAML-formatted config files are also supported.
|
||||
In JSON format, create a new config file (e.g. `immich.json`) and put it in a location that can be accessed by Immich.
|
||||
The default configuration looks like this:
|
||||
|
||||
<details>
|
||||
@@ -252,15 +251,6 @@ So you can just grab it from there, paste it into a file and you're pretty much
|
||||
In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
|
||||
For more information, refer to the [Environment Variables](/install/environment-variables.md) section.
|
||||
|
||||
:::info Docker Compose
|
||||
In your `.env` file, the variables `UPLOAD_LOCATION` and `DB_DATA_LOCATION` concern the location on the host.
|
||||
However, the variable `IMMICH_CONFIG_FILE` concerns the location inside the container, and informs the `immich-server` container that a configuration file is present.
|
||||
|
||||
It is recommended to reuse this variable in your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./configuration.yml:${IMMICH_CONFIG_FILE}
|
||||
```
|
||||
|
||||
::
|
||||
:::tip
|
||||
YAML-formatted config files are also supported.
|
||||
:::
|
||||
|
||||
@@ -8,6 +8,8 @@ sidebar_position: 85
|
||||
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
||||
|
||||
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
|
||||
|
||||
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
||||
:::
|
||||
|
||||
Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager.
|
||||
|
||||
@@ -23,9 +23,3 @@ run = "prettier --check ."
|
||||
[tasks."format-fix"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks.deploy]
|
||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||
|
||||
[tools]
|
||||
wrangler = "4.66.0"
|
||||
|
||||
@@ -58,6 +58,6 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.13.0
|
||||
|
||||
@@ -1,77 +1,86 @@
|
||||
name: immich-e2e
|
||||
|
||||
services:
|
||||
immich-app-base:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: immich-app-base
|
||||
|
||||
immich-init:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: immich-init
|
||||
container_name: immich-e2e-init
|
||||
|
||||
immich-server:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: immich-server
|
||||
container_name: immich-e2e-server
|
||||
ports: !reset []
|
||||
env_file: !reset []
|
||||
command: ['immich-dev']
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile.dev
|
||||
target: dev
|
||||
environment:
|
||||
DB_HOSTNAME: database
|
||||
DB_USERNAME: postgres
|
||||
DB_PASSWORD: postgres
|
||||
DB_DATABASE_NAME: immich
|
||||
IMMICH_MACHINE_LEARNING_ENABLED: 'false'
|
||||
IMMICH_TELEMETRY_INCLUDE: all
|
||||
IMMICH_ENV: testing
|
||||
IMMICH_PORT: '2285'
|
||||
IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true'
|
||||
- DB_HOSTNAME=database
|
||||
- DB_USERNAME=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_DATABASE_NAME=immich
|
||||
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
||||
- IMMICH_TELEMETRY_INCLUDE=all
|
||||
- IMMICH_ENV=testing
|
||||
- IMMICH_PORT=2285
|
||||
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
||||
volumes:
|
||||
- ./test-assets:/test-assets
|
||||
- ..:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- ../plugins:/build/corePlugin
|
||||
depends_on:
|
||||
immich-init:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
immich-web:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: immich-web
|
||||
container_name: immich-e2e-web
|
||||
ports: !override
|
||||
image: immich-web-dev:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile.dev
|
||||
target: dev
|
||||
command: ['immich-web']
|
||||
ports:
|
||||
- 2285:3000
|
||||
environment:
|
||||
IMMICH_SERVER_URL: http://immich-server:2285/
|
||||
depends_on:
|
||||
immich-init:
|
||||
condition: service_healthy
|
||||
- IMMICH_SERVER_URL=http://immich-server:2285/
|
||||
volumes:
|
||||
- ..:/usr/src/app
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: redis
|
||||
container_name: immich-e2e-redis
|
||||
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
|
||||
|
||||
database:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: database
|
||||
container_name: immich-e2e-postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||
env_file: !reset []
|
||||
ports: !override
|
||||
- 5435:5432
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: immich
|
||||
ports:
|
||||
- 5435:5432
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
||||
interval: 1s
|
||||
@@ -80,19 +89,17 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
model_cache:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
pnpm_cache:
|
||||
pnpm_store_server:
|
||||
pnpm_store_web:
|
||||
server_node_modules:
|
||||
web_node_modules:
|
||||
github_node_modules:
|
||||
cli_node_modules:
|
||||
docs_node_modules:
|
||||
e2e_node_modules:
|
||||
sdk_node_modules:
|
||||
app_node_modules:
|
||||
model-cache:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
pnpm-store:
|
||||
server-node_modules:
|
||||
web-node_modules:
|
||||
github-node_modules:
|
||||
cli-node_modules:
|
||||
docs-node_modules:
|
||||
e2e-node_modules:
|
||||
sdk-node_modules:
|
||||
app-node_modules:
|
||||
sveltekit:
|
||||
coverage:
|
||||
|
||||
@@ -2,7 +2,6 @@ name: immich-e2e
|
||||
|
||||
services:
|
||||
e2e-auth-server:
|
||||
container_name: immich-e2e-auth-server
|
||||
build:
|
||||
context: ../e2e-auth-server
|
||||
ports:
|
||||
@@ -11,6 +10,7 @@ services:
|
||||
immich-server:
|
||||
container_name: immich-e2e-server
|
||||
image: immich-server:latest
|
||||
shm_size: 128mb
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile
|
||||
@@ -23,15 +23,15 @@ services:
|
||||
- BUILD_SOURCE_REF=e2e
|
||||
- BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee
|
||||
environment:
|
||||
DB_HOSTNAME: database
|
||||
DB_USERNAME: postgres
|
||||
DB_PASSWORD: postgres
|
||||
DB_DATABASE_NAME: immich
|
||||
IMMICH_MACHINE_LEARNING_ENABLED: 'false'
|
||||
IMMICH_TELEMETRY_INCLUDE: all
|
||||
IMMICH_ENV: testing
|
||||
IMMICH_PORT: '2285'
|
||||
IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true'
|
||||
- DB_HOSTNAME=database
|
||||
- DB_USERNAME=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_DATABASE_NAME=immich
|
||||
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
||||
- IMMICH_TELEMETRY_INCLUDE=all
|
||||
- IMMICH_ENV=testing
|
||||
- IMMICH_PORT=2285
|
||||
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
||||
volumes:
|
||||
- ./test-assets:/test-assets
|
||||
depends_on:
|
||||
@@ -43,14 +43,10 @@ services:
|
||||
- 2285:2285
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
|
||||
|
||||
database:
|
||||
container_name: immich-e2e-postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
@@ -7,13 +7,8 @@
|
||||
"scripts": {
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
|
||||
"test:web": "npx playwright test --project=web",
|
||||
"test:web:maintenance": "npx playwright test --project=maintenance",
|
||||
"test:web:ui": "npx playwright test --project=ui",
|
||||
"start:web": "npx playwright test --ui --project=web",
|
||||
"start:web:maintenance": "npx playwright test --ui --project=maintenance",
|
||||
"start:web:ui": "npx playwright test --ui --project=ui",
|
||||
"test:web": "npx playwright test",
|
||||
"start:web": "npx playwright test --ui",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
@@ -26,13 +21,13 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@immich/cli": "workspace:*",
|
||||
"@immich/e2e-auth-server": "workspace:*",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/cli": "file:../cli",
|
||||
"@immich/e2e-auth-server": "file:../e2e-auth-server",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.11",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
@@ -57,6 +52,6 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import dotenv from 'dotenv';
|
||||
import { cpus } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
dotenv.config({ quiet: true, path: resolve(import.meta.dirname, '.env') });
|
||||
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
||||
|
||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||
@@ -48,7 +48,7 @@ const config: PlaywrightTestConfig = {
|
||||
{
|
||||
name: 'maintenance',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testDir: './src/specs/maintenance/web',
|
||||
testDir: './src/specs/maintenance',
|
||||
workers: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
|
||||
// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set
|
||||
// skip `docker compose up` if `make e2e` was already run
|
||||
const globalSetup: string[] = [];
|
||||
if (!skipDockerSetup) {
|
||||
try {
|
||||
await fetch('http://127.0.0.1:2285/api/server/ping');
|
||||
} catch {
|
||||
globalSetup.push('src/docker-compose.ts');
|
||||
}
|
||||
try {
|
||||
await fetch('http://127.0.0.1:2285/api/server/ping');
|
||||
} catch {
|
||||
globalSetup.push('src/docker-compose.ts');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
retry: process.env.CI ? 4 : 0,
|
||||
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
||||
globalSetup,
|
||||
testTimeout: 15_000,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
|
||||
// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set
|
||||
const globalSetup: string[] = [];
|
||||
if (!skipDockerSetup) {
|
||||
try {
|
||||
await fetch('http://127.0.0.1:2285/api/server/ping');
|
||||
} catch {
|
||||
globalSetup.push('src/docker-compose.ts');
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
retry: process.env.CI ? 4 : 0,
|
||||
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
||||
globalSetup,
|
||||
testTimeout: 15_000,
|
||||
pool: 'threads',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1218,7 +1218,6 @@
|
||||
"filter_description": "Conditions to filter the target assets",
|
||||
"filter_people": "Filter people",
|
||||
"filter_places": "Filter places",
|
||||
"filter_tags": "Filter tags",
|
||||
"filters": "Filters",
|
||||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
@@ -1946,7 +1945,6 @@
|
||||
"search_filter_ocr": "Search by OCR",
|
||||
"search_filter_people_title": "Select people",
|
||||
"search_filter_star_rating": "Star Rating",
|
||||
"search_filter_tags_title": "Select tags",
|
||||
"search_for": "Search for",
|
||||
"search_for_existing_person": "Search for existing person",
|
||||
"search_no_more_result": "No more results",
|
||||
|
||||
@@ -8,6 +8,7 @@ readme = "README.md"
|
||||
dependencies = [
|
||||
"aiocache>=0.12.1,<1.0",
|
||||
"fastapi>=0.95.2,<1.0",
|
||||
"ftfy>=6.1.1",
|
||||
"gunicorn>=21.1.0",
|
||||
"huggingface-hub>=0.20.1,<1.0",
|
||||
"insightface>=0.7.3,<1.0",
|
||||
|
||||
11
mise.toml
11
mise.toml
@@ -14,15 +14,15 @@ config_roots = [
|
||||
]
|
||||
|
||||
[tools]
|
||||
node = "24.13.1"
|
||||
node = "24.13.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.29.3"
|
||||
pnpm = "10.28.2"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
java = "21.0.2"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.35.1"
|
||||
version = "1.30.0"
|
||||
bin = "dcm"
|
||||
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
||||
|
||||
@@ -37,12 +37,13 @@ run = "pnpm install --filter @immich/sdk --frozen-lockfile"
|
||||
|
||||
[tasks."sdk:build"]
|
||||
dir = "open-api/typescript-sdk"
|
||||
run = "pnpm run build"
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "tsc"
|
||||
|
||||
# i18n tasks
|
||||
[tasks."i18n:format"]
|
||||
dir = "i18n"
|
||||
run = "pnpm run format"
|
||||
run = { task = ":i18n:format-fix" }
|
||||
|
||||
[tasks."i18n:format-fix"]
|
||||
dir = "i18n"
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: '>=1.29.0 <=1.36.0'
|
||||
version: '>=1.29.0 <=1.30.0'
|
||||
|
||||
@@ -16,8 +16,9 @@ class ScrollToDateEvent extends Event {
|
||||
}
|
||||
|
||||
// Asset Viewer Events
|
||||
class ViewerShowDetailsEvent extends Event {
|
||||
const ViewerShowDetailsEvent();
|
||||
class ViewerOpenBottomSheetEvent extends Event {
|
||||
final bool activitiesMode;
|
||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||
}
|
||||
|
||||
class ViewerReloadAssetEvent extends Event {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class Tag {
|
||||
final String id;
|
||||
final String value;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static Tag fromDto(TagResponseDto dto) {
|
||||
return Tag(id: dto.id, value: dto.value);
|
||||
}
|
||||
}
|
||||
@@ -183,8 +183,8 @@ class TimelineService {
|
||||
return _buffer.slice(start, start + count);
|
||||
}
|
||||
|
||||
// Preload assets around the given index for asset viewer
|
||||
Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||
// Pre-cache assets around the given index for asset viewer
|
||||
Future<void> preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||
|
||||
BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length));
|
||||
|
||||
|
||||
@@ -32,125 +32,3 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
||||
damping: 80,
|
||||
);
|
||||
}
|
||||
|
||||
class SnapScrollPhysics extends ScrollPhysics {
|
||||
static const _minFlingVelocity = 700.0;
|
||||
static const minSnapDistance = 30.0;
|
||||
|
||||
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
||||
|
||||
const SnapScrollPhysics({super.parent});
|
||||
|
||||
@override
|
||||
SnapScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return SnapScrollPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
@override
|
||||
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
|
||||
assert(
|
||||
position is SnapScrollPosition,
|
||||
'SnapScrollPhysics can only be used with Scrollables that use a '
|
||||
'controller whose createScrollPosition returns a SnapScrollPosition',
|
||||
);
|
||||
|
||||
final snapOffset = (position as SnapScrollPosition).snapOffset;
|
||||
if (snapOffset <= 0) {
|
||||
return super.createBallisticSimulation(position, velocity);
|
||||
}
|
||||
|
||||
if (position.pixels >= snapOffset) {
|
||||
final simulation = super.createBallisticSimulation(position, velocity);
|
||||
if (simulation == null || simulation.x(double.infinity) >= snapOffset) {
|
||||
return simulation;
|
||||
}
|
||||
}
|
||||
|
||||
return ScrollSpringSimulation(
|
||||
_spring,
|
||||
position.pixels,
|
||||
target(position, velocity, snapOffset),
|
||||
velocity,
|
||||
tolerance: toleranceFor(position),
|
||||
);
|
||||
}
|
||||
|
||||
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
||||
if (velocity > _minFlingVelocity) return snapOffset;
|
||||
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
||||
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
||||
}
|
||||
}
|
||||
|
||||
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
||||
double snapOffset;
|
||||
|
||||
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
|
||||
}
|
||||
|
||||
class ProxyScrollController extends ScrollController {
|
||||
final ScrollController scrollController;
|
||||
|
||||
ProxyScrollController({required this.scrollController});
|
||||
|
||||
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
||||
|
||||
@override
|
||||
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
|
||||
return ProxyScrollPosition(
|
||||
scrollController: scrollController,
|
||||
physics: physics,
|
||||
context: context,
|
||||
oldPosition: oldPosition,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ProxyScrollPosition extends SnapScrollPosition {
|
||||
final ScrollController scrollController;
|
||||
|
||||
ProxyScrollPosition({
|
||||
required this.scrollController,
|
||||
required super.physics,
|
||||
required super.context,
|
||||
super.oldPosition,
|
||||
});
|
||||
|
||||
@override
|
||||
double setPixels(double newPixels) {
|
||||
final overscroll = super.setPixels(newPixels);
|
||||
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||
scrollController.position.forcePixels(pixels);
|
||||
}
|
||||
return overscroll;
|
||||
}
|
||||
|
||||
@override
|
||||
void forcePixels(double value) {
|
||||
super.forcePixels(value);
|
||||
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||
scrollController.position.forcePixels(pixels);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||
? scrollController.position.maxScrollExtent
|
||||
: super.maxScrollExtent;
|
||||
|
||||
@override
|
||||
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||
? scrollController.position.minScrollExtent
|
||||
: super.minScrollExtent;
|
||||
|
||||
@override
|
||||
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
|
||||
? scrollController.position.viewportDimension
|
||||
: super.viewportDimension;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ class SearchApiRepository extends ApiRepository {
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
tagIds: filter.tagIds,
|
||||
type: type,
|
||||
page: page,
|
||||
size: 100,
|
||||
@@ -60,7 +59,6 @@ class SearchApiRepository extends ApiRepository {
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
tagIds: filter.tagIds,
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final tagsApiRepositoryProvider = Provider<TagsApiRepository>(
|
||||
(ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi),
|
||||
);
|
||||
|
||||
class TagsApiRepository extends ApiRepository {
|
||||
final TagsApi _api;
|
||||
const TagsApiRepository(this._api);
|
||||
|
||||
Future<List<TagResponseDto>?> getAllTags() async {
|
||||
return await _api.getAllTags();
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,6 @@ class SearchFilter {
|
||||
String? ocr;
|
||||
String? language;
|
||||
String? assetId;
|
||||
List<String>? tagIds;
|
||||
Set<PersonDto> people;
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
@@ -232,7 +231,6 @@ class SearchFilter {
|
||||
this.ocr,
|
||||
this.language,
|
||||
this.assetId,
|
||||
this.tagIds,
|
||||
required this.people,
|
||||
required this.location,
|
||||
required this.camera,
|
||||
@@ -248,7 +246,6 @@ class SearchFilter {
|
||||
(description == null || (description!.isEmpty)) &&
|
||||
(assetId == null || (assetId!.isEmpty)) &&
|
||||
(ocr == null || (ocr!.isEmpty)) &&
|
||||
(tagIds ?? []).isEmpty &&
|
||||
people.isEmpty &&
|
||||
location.country == null &&
|
||||
location.state == null &&
|
||||
@@ -272,7 +269,6 @@ class SearchFilter {
|
||||
String? ocr,
|
||||
String? assetId,
|
||||
Set<PersonDto>? people,
|
||||
List<String>? tagIds,
|
||||
SearchLocationFilter? location,
|
||||
SearchCameraFilter? camera,
|
||||
SearchDateFilter? date,
|
||||
@@ -294,13 +290,12 @@ class SearchFilter {
|
||||
display: display ?? this.display,
|
||||
rating: rating ?? this.rating,
|
||||
mediaType: mediaType ?? this.mediaType,
|
||||
tagIds: tagIds ?? this.tagIds,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -314,7 +309,6 @@ class SearchFilter {
|
||||
other.ocr == ocr &&
|
||||
other.assetId == assetId &&
|
||||
other.people == people &&
|
||||
other.tagIds == tagIds &&
|
||||
other.location == location &&
|
||||
other.camera == camera &&
|
||||
other.date == date &&
|
||||
@@ -332,7 +326,6 @@ class SearchFilter {
|
||||
ocr.hashCode ^
|
||||
assetId.hashCode ^
|
||||
people.hashCode ^
|
||||
tagIds.hashCode ^
|
||||
location.hashCode ^
|
||||
camera.hashCode ^
|
||||
date.hashCode ^
|
||||
|
||||
@@ -6,7 +6,6 @@ class ServerFeatures {
|
||||
final bool oauthEnabled;
|
||||
final bool passwordLogin;
|
||||
final bool ocr;
|
||||
final bool smartSearch;
|
||||
|
||||
const ServerFeatures({
|
||||
required this.trash,
|
||||
@@ -14,30 +13,21 @@ class ServerFeatures {
|
||||
required this.oauthEnabled,
|
||||
required this.passwordLogin,
|
||||
this.ocr = false,
|
||||
this.smartSearch = false,
|
||||
});
|
||||
|
||||
ServerFeatures copyWith({
|
||||
bool? trash,
|
||||
bool? map,
|
||||
bool? oauthEnabled,
|
||||
bool? passwordLogin,
|
||||
bool? ocr,
|
||||
bool? smartSearch,
|
||||
}) {
|
||||
ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin, bool? ocr}) {
|
||||
return ServerFeatures(
|
||||
trash: trash ?? this.trash,
|
||||
map: map ?? this.map,
|
||||
oauthEnabled: oauthEnabled ?? this.oauthEnabled,
|
||||
passwordLogin: passwordLogin ?? this.passwordLogin,
|
||||
ocr: ocr ?? this.ocr,
|
||||
smartSearch: smartSearch ?? this.smartSearch,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)';
|
||||
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr)';
|
||||
}
|
||||
|
||||
ServerFeatures.fromDto(ServerFeaturesDto dto)
|
||||
@@ -45,8 +35,7 @@ class ServerFeatures {
|
||||
map = dto.map,
|
||||
oauthEnabled = dto.oauth,
|
||||
passwordLogin = dto.passwordLogin,
|
||||
ocr = dto.ocr,
|
||||
smartSearch = dto.smartSearch;
|
||||
ocr = dto.ocr;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant ServerFeatures other) {
|
||||
@@ -56,17 +45,11 @@ class ServerFeatures {
|
||||
other.map == map &&
|
||||
other.oauthEnabled == oauthEnabled &&
|
||||
other.passwordLogin == passwordLogin &&
|
||||
other.ocr == ocr &&
|
||||
other.smartSearch == smartSearch;
|
||||
other.ocr == ocr;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return trash.hashCode ^
|
||||
map.hashCode ^
|
||||
oauthEnabled.hashCode ^
|
||||
passwordLogin.hashCode ^
|
||||
ocr.hashCode ^
|
||||
smartSearch.hashCode;
|
||||
return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final user = sharedUsers.value[index];
|
||||
return ListTile(
|
||||
leading: UserCircleAvatar(user: user),
|
||||
leading: UserCircleAvatar(user: user, radius: 22),
|
||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
|
||||
|
||||
@@ -41,7 +41,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget {
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: UserCircleAvatar(user: sharedUsers.value[index], size: 36),
|
||||
child: UserCircleAvatar(user: sharedUsers.value[index], radius: 18, size: 36),
|
||||
);
|
||||
}),
|
||||
itemCount: sharedUsers.value.length,
|
||||
|
||||
@@ -118,7 +118,7 @@ class MapPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
// finds the nearest asset marker from the tap point and store it as the selectedMarker
|
||||
Future<void> onMarkerClicked(Point<double> point, LatLng _) async {
|
||||
Future<void> onMarkerClicked(Point<double> point, LatLng coords) async {
|
||||
// Guard map not created
|
||||
if (mapController.value == null) {
|
||||
return;
|
||||
|
||||
@@ -28,7 +28,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
||||
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
|
||||
}
|
||||
|
||||
Future<void> onMapClick(Point<num> _, LatLng centre) async {
|
||||
Future<void> onMapClick(Point<num> point, LatLng centre) async {
|
||||
selectedLatLng.value = centre;
|
||||
await controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
|
||||
if (marker.value != null) {
|
||||
|
||||
100
mobile/lib/presentation/pages/dev/ui_showcase.page.dart
Normal file
100
mobile/lib/presentation/pages/dev/ui_showcase.page.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
|
||||
final children = <Widget>[];
|
||||
|
||||
final items = [
|
||||
(variant: ImmichVariant.filled, title: "Filled Variant"),
|
||||
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
|
||||
];
|
||||
|
||||
for (final (:variant, :title) in items) {
|
||||
children.add(Text(title));
|
||||
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
class _ComponentTitle extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _ComponentTitle(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(title, style: context.textTheme.titleLarge);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class ImmichUIShowcasePage extends StatelessWidget {
|
||||
const ImmichUIShowcasePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Immich UI Showcase')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 10,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _ComponentTitle("IconButton"),
|
||||
..._showcaseBuilder(
|
||||
(variant, color) =>
|
||||
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}),
|
||||
),
|
||||
const _ComponentTitle("CloseButton"),
|
||||
..._showcaseBuilder(
|
||||
(variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}),
|
||||
),
|
||||
const _ComponentTitle("TextButton"),
|
||||
|
||||
ImmichTextButton(
|
||||
labelText: "Text Button",
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.filled,
|
||||
color: ImmichColor.primary,
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: "Text Button",
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.filled,
|
||||
color: ImmichColor.primary,
|
||||
loading: true,
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: "Text Button",
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.primary,
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: "Text Button",
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.primary,
|
||||
loading: true,
|
||||
),
|
||||
const _ComponentTitle("Form"),
|
||||
ImmichForm(
|
||||
onSubmit: () {},
|
||||
child: const Column(
|
||||
spacing: 10,
|
||||
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,13 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
@RoutePage()
|
||||
class DriftActivitiesPage extends HookConsumerWidget {
|
||||
final RemoteAlbum album;
|
||||
final String? assetId;
|
||||
final String? assetName;
|
||||
|
||||
const DriftActivitiesPage({super.key, required this.album, this.assetId, this.assetName});
|
||||
const DriftActivitiesPage({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id, assetId));
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id));
|
||||
final listViewScrollController = useScrollController();
|
||||
|
||||
void scrollToBottom() {
|
||||
@@ -38,13 +36,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(album.name),
|
||||
if (assetName != null) Text(assetName!, style: context.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
title: Text(album.name),
|
||||
actions: [const LikeActivityActionButton(iconOnly: true)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
@@ -55,7 +47,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
activityWidgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: CommentBubble(activity: activity, isAssetActivity: assetId != null),
|
||||
child: CommentBubble(activity: activity),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
||||
pinned: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_rounded, size: 28),
|
||||
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
],
|
||||
showUploadButton: false,
|
||||
|
||||
@@ -149,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: UserCircleAvatar(user: user),
|
||||
leading: UserCircleAvatar(user: user, radius: 22),
|
||||
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),
|
||||
@@ -169,7 +169,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final user = sharedUsers[index];
|
||||
return ListTile(
|
||||
leading: UserCircleAvatar(user: user),
|
||||
leading: UserCircleAvatar(user: user, radius: 22),
|
||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
|
||||
|
||||
@@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.wid
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
@@ -21,11 +20,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/feature_check.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
|
||||
@@ -42,15 +39,8 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
|
||||
final textSearchType = useState<TextSearchType>(
|
||||
serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename,
|
||||
);
|
||||
final searchHintText = useState<String>(
|
||||
serverFeatures.smartSearch
|
||||
? 'sunrise_on_the_beach'.t(context: context)
|
||||
: 'file_name_or_extension'.t(context: context),
|
||||
);
|
||||
final textSearchType = useState<TextSearchType>(TextSearchType.context);
|
||||
final searchHintText = useState<String>('sunrise_on_the_beach'.t(context: context));
|
||||
final textSearchController = useTextEditingController();
|
||||
final preFilter = ref.watch(searchPreFilterProvider);
|
||||
final filter = useState<SearchFilter>(
|
||||
@@ -64,7 +54,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
mediaType: preFilter?.mediaType ?? AssetType.other,
|
||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||
assetId: preFilter?.assetId,
|
||||
tagIds: preFilter?.tagIds ?? [],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -75,14 +64,15 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||
final tagCurrentFilterWidget = useState<Widget?>(null);
|
||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
|
||||
final isSearching = useState(false);
|
||||
|
||||
final userPreferences = ref.watch(userMetadataPreferencesProvider);
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
SnackBar searchInfoSnackBar(String message) {
|
||||
return SnackBar(
|
||||
@@ -150,12 +140,10 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
handleOnSelect(Set<PersonDto> value) {
|
||||
filter.value = filter.value.copyWith(people: value);
|
||||
|
||||
final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
|
||||
if (label.isNotEmpty) {
|
||||
peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge);
|
||||
} else {
|
||||
peopleCurrentFilterWidget.value = null;
|
||||
}
|
||||
peopleCurrentFilterWidget.value = Text(
|
||||
value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
@@ -181,42 +169,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
showTagPicker() {
|
||||
handleOnSelect(Iterable<Tag> tags) {
|
||||
filter.value = filter.value.copyWith(tagIds: tags.map((t) => t.id).toList());
|
||||
final label = tags.map((t) => t.value).join(', ');
|
||||
if (label.isEmpty) {
|
||||
tagCurrentFilterWidget.value = null;
|
||||
} else {
|
||||
tagCurrentFilterWidget.value = Text(
|
||||
label.isEmpty ? 'tags'.t(context: context) : label,
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(tagIds: []);
|
||||
tagCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
child: FractionallySizedBox(
|
||||
heightFactor: 0.8,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_tags_title'.t(context: context),
|
||||
expanded: true,
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
showLocationPicker() {
|
||||
handleOnSelect(Map<String, String?> value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
@@ -566,26 +518,23 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
menuChildren: [
|
||||
FeatureCheck(
|
||||
feature: (features) => features.smartSearch,
|
||||
child: MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.image_search_rounded),
|
||||
title: Text(
|
||||
'search_by_context'.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
|
||||
),
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.image_search_rounded),
|
||||
title: Text(
|
||||
'search_by_context'.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.context,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.context;
|
||||
searchHintText.value = 'sunrise_on_the_beach'.t(context: context);
|
||||
},
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.context,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.context;
|
||||
searchHintText.value = 'sunrise_on_the_beach'.t(context: context);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
@@ -698,13 +647,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_location'.t(context: context),
|
||||
currentFilter: locationCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.value?.tagsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.sell_outlined,
|
||||
onTap: showTagPicker,
|
||||
label: 'tags'.t(context: context),
|
||||
currentFilter: tagCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.camera_alt_outlined,
|
||||
onTap: showCameraPicker,
|
||||
@@ -724,7 +666,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.value?.ratingsEnabled ?? false) ...[
|
||||
if (isRatingEnabled) ...[
|
||||
SearchFilterChip(
|
||||
icon: Icons.star_outline_rounded,
|
||||
onTap: showStarRatingPicker,
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@@ -88,7 +88,7 @@ class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField>
|
||||
prefixIcon: user != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: UserCircleAvatar(user: user, size: 30),
|
||||
child: UserCircleAvatar(user: user, size: 30, radius: 15),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: IconButton(
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
|
||||
class ActivitiesBottomSheet extends HookConsumerWidget {
|
||||
final DraggableScrollableController controller;
|
||||
final double initialChildSize;
|
||||
final bool scrollToBottomInitially;
|
||||
|
||||
const ActivitiesBottomSheet({
|
||||
required this.controller,
|
||||
this.initialChildSize = 0.35,
|
||||
this.scrollToBottomInitially = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentRemoteAlbumProvider)!;
|
||||
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
|
||||
|
||||
Future<void> onAddComment(String comment) async {
|
||||
await activityNotifier.addComment(comment);
|
||||
}
|
||||
|
||||
Widget buildActivitiesSliver() {
|
||||
return activities.widgetWhen(
|
||||
onLoading: () => const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
onData: (data) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == data.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final activity = data[data.length - 1 - index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: CommentBubble(activity: activity, isAssetActivity: true),
|
||||
);
|
||||
}, childCount: data.length + 1),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
actions: [],
|
||||
slivers: [buildActivitiesSliver()],
|
||||
footer: Padding(
|
||||
// TODO: avoid fixed padding, use context.padding.bottom
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
const Divider(indent: 16, endIndent: 16),
|
||||
DriftActivityTextField(
|
||||
isEnabled: album.isActivityEnabled,
|
||||
isBottomSheet: true,
|
||||
// likeId: likedId,
|
||||
onSubmit: onAddComment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
controller: controller,
|
||||
initialChildSize: initialChildSize,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: 0.88,
|
||||
expand: false,
|
||||
shouldCloseOnMinExtent: false,
|
||||
resizeOnScroll: false,
|
||||
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
|
||||
class AssetDetails extends ConsumerWidget {
|
||||
final double minHeight;
|
||||
|
||||
const AssetDetails({required this.minHeight, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const DragHandle(),
|
||||
const DateTimeDetails(),
|
||||
const PeopleDetails(),
|
||||
const LocationDetails(),
|
||||
const TechnicalDetails(),
|
||||
const RatingDetails(),
|
||||
const AppearsInDetails(),
|
||||
SizedBox(height: context.padding.bottom + 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.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/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/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AppearsInDetails extends ConsumerWidget {
|
||||
const AppearsInDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
|
||||
|
||||
String? remoteAssetId;
|
||||
if (asset is RemoteAsset) {
|
||||
remoteAssetId = asset.id;
|
||||
} else if (asset is LocalAsset) {
|
||||
remoteAssetId = asset.remoteAssetId;
|
||||
}
|
||||
|
||||
if (remoteAssetId == null) return const SizedBox.shrink();
|
||||
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
|
||||
|
||||
return assetAlbums.when(
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
albums.sortBy((a) => a.name);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
SheetTile(
|
||||
title: 'appears_in'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: albums.map((album) {
|
||||
final isOwner = album.ownerId == userId;
|
||||
return AlbumTile(
|
||||
album: album,
|
||||
isOwner: isOwner,
|
||||
onAlbumSelected: (album) async {
|
||||
ref.invalidate(assetViewerProvider);
|
||||
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class DateTimeDetails extends ConsumerWidget {
|
||||
const DateTimeDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner
|
||||
? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context)
|
||||
: null,
|
||||
),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
||||
DateTime dateTime = asset.createdAt.toLocal();
|
||||
Duration timeZoneOffset = dateTime.timeZoneOffset;
|
||||
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
(dateTime, timeZoneOffset) = applyTimezoneOffset(
|
||||
dateTime: exifInfo!.dateTimeOriginal!,
|
||||
timeZone: exifInfo.timeZone,
|
||||
);
|
||||
}
|
||||
|
||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
|
||||
return '$date$_kSeparator$time $timezone';
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||
final ExifInfo exif;
|
||||
final bool isEditable;
|
||||
|
||||
const _SheetAssetDescription({required this.exif, this.isEditable = true});
|
||||
|
||||
@override
|
||||
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
||||
}
|
||||
|
||||
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
|
||||
late TextEditingController _controller;
|
||||
final _descriptionFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.exif.description ?? '');
|
||||
}
|
||||
|
||||
Future<void> saveDescription(String? previousDescription) async {
|
||||
final newDescription = _controller.text.trim();
|
||||
|
||||
if (newDescription == previousDescription) {
|
||||
_descriptionFocus.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
|
||||
|
||||
if (!editAction.success) {
|
||||
_controller.text = previousDescription ?? '';
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'exif_bottom_sheet_description_error'.t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
_descriptionFocus.unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
final currentDescription = currentExifInfo?.description ?? '';
|
||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||
context: context,
|
||||
);
|
||||
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
||||
_controller.text = currentDescription;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.isEditable,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
focusNode: _descriptionFocus,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class DragHandle extends StatelessWidget {
|
||||
const DragHandle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(2)),
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
|
||||
class RatingDetails extends ConsumerWidget {
|
||||
const RatingDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
if (!isRatingEnabled) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'rating'.t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
RatingBar(
|
||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||
filledColor: context.themeData.colorScheme.primary,
|
||||
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
|
||||
itemSize: 40,
|
||||
onRatingUpdate: (rating) async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
|
||||
},
|
||||
onClearRating: () async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.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/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class TechnicalDetails extends ConsumerWidget {
|
||||
const TechnicalDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SheetTile(
|
||||
title: 'details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
_buildFileInfoTile(context, ref, asset, exifInfo),
|
||||
if (cameraTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
if (lensTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFileInfoTile(BuildContext context, WidgetRef ref, BaseAsset asset, ExifInfo? exifInfo) {
|
||||
final icon = Icon(
|
||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
||||
size: 24,
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
);
|
||||
final subtitle = _getFileInfo(asset, exifInfo);
|
||||
final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary);
|
||||
|
||||
if (asset is LocalAsset) {
|
||||
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
|
||||
return FutureBuilder<String?>(
|
||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
||||
builder: (context, snapshot) {
|
||||
return SheetTile(
|
||||
title: snapshot.data ?? asset.name,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: icon,
|
||||
subtitle: subtitle,
|
||||
subtitleStyle: subtitleStyle,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return SheetTile(
|
||||
title: asset.name,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: icon,
|
||||
subtitle: subtitle,
|
||||
subtitleStyle: subtitleStyle,
|
||||
);
|
||||
}
|
||||
|
||||
static String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
||||
final height = asset.height;
|
||||
final width = asset.width;
|
||||
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
|
||||
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
|
||||
|
||||
return switch ((fileSize, resolution)) {
|
||||
(null, null) => '',
|
||||
(String fileSize, null) => fileSize,
|
||||
(null, String resolution) => resolution,
|
||||
(String fileSize, String resolution) => '$fileSize$_kSeparator$resolution',
|
||||
};
|
||||
}
|
||||
|
||||
static String? _getCameraInfoTitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) return null;
|
||||
return switch ((exifInfo.make, exifInfo.model)) {
|
||||
(null, null) => null,
|
||||
(String make, null) => make,
|
||||
(null, String model) => model,
|
||||
(String make, String model) => '$make $model',
|
||||
};
|
||||
}
|
||||
|
||||
static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) return null;
|
||||
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
||||
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
||||
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
|
||||
static String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) return null;
|
||||
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/gestures.dart' show Drag, kTouchSlop;
|
||||
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/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/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
enum _DragIntent { none, scroll, dismiss }
|
||||
|
||||
class AssetPage extends ConsumerStatefulWidget {
|
||||
final int index;
|
||||
final int heroOffset;
|
||||
|
||||
const AssetPage({super.key, required this.index, required this.heroOffset});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _AssetPageState();
|
||||
}
|
||||
|
||||
class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
PhotoViewControllerBase? _viewController;
|
||||
StreamSubscription? _scaleBoundarySub;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
AssetViewerStateNotifier get _viewer => ref.read(assetViewerProvider.notifier);
|
||||
|
||||
late PhotoViewControllerValue _initialPhotoViewState;
|
||||
|
||||
bool _showingDetails = false;
|
||||
bool _isZoomed = false;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||
|
||||
double _snapOffset = 0.0;
|
||||
double _lastScrollOffset = 0.0;
|
||||
|
||||
DragStartDetails? _dragStart;
|
||||
_DragIntent _dragIntent = _DragIntent.none;
|
||||
Drag? _drag;
|
||||
bool _shouldPopOnDrag = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_proxyScrollController.addListener(_onScroll);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_proxyScrollController.hasClients) return;
|
||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||
if (_showingDetails && _snapOffset > 0) {
|
||||
_proxyScrollController.jumpTo(_snapOffset);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_proxyScrollController.dispose();
|
||||
_scaleBoundarySub?.cancel();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
switch (event) {
|
||||
case ViewerShowDetailsEvent():
|
||||
_showDetails();
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void _showDetails() {
|
||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
|
||||
_lastScrollOffset = _proxyScrollController.offset;
|
||||
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
bool _willClose(double scrollVelocity) {
|
||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
|
||||
|
||||
final position = _proxyScrollController.position;
|
||||
return _proxyScrollController.position.pixels < _snapOffset &&
|
||||
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final offset = _proxyScrollController.offset;
|
||||
if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) {
|
||||
_viewer.setShowingDetails(true);
|
||||
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
|
||||
_viewer.setShowingDetails(false);
|
||||
}
|
||||
_lastScrollOffset = offset;
|
||||
}
|
||||
|
||||
void _beginDrag(DragStartDetails details) {
|
||||
_dragStart = details;
|
||||
_shouldPopOnDrag = false;
|
||||
_lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0;
|
||||
|
||||
if (_viewController != null) {
|
||||
_initialPhotoViewState = _viewController!.value;
|
||||
}
|
||||
|
||||
if (_showingDetails) {
|
||||
_dragIntent = _DragIntent.scroll;
|
||||
_startProxyDrag();
|
||||
}
|
||||
}
|
||||
|
||||
void _startProxyDrag() {
|
||||
if (_proxyScrollController.hasClients && _dragStart != null) {
|
||||
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateDrag(DragUpdateDetails details) {
|
||||
if (_dragStart == null) return;
|
||||
|
||||
if (_dragIntent == _DragIntent.none) {
|
||||
_dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) {
|
||||
< 0 => _DragIntent.scroll,
|
||||
> 0 => _DragIntent.dismiss,
|
||||
_ => _DragIntent.none,
|
||||
};
|
||||
}
|
||||
|
||||
switch (_dragIntent) {
|
||||
case _DragIntent.none:
|
||||
case _DragIntent.scroll:
|
||||
if (_drag == null) _startProxyDrag();
|
||||
_drag?.update(details);
|
||||
case _DragIntent.dismiss:
|
||||
_handleDragDown(context, details.localPosition - _dragStart!.localPosition);
|
||||
}
|
||||
}
|
||||
|
||||
void _endDrag(DragEndDetails details) {
|
||||
if (_dragStart == null) return;
|
||||
|
||||
_dragStart = null;
|
||||
|
||||
final intent = _dragIntent;
|
||||
_dragIntent = _DragIntent.none;
|
||||
|
||||
switch (intent) {
|
||||
case _DragIntent.none:
|
||||
case _DragIntent.scroll:
|
||||
final scrollVelocity = -(details.primaryVelocity ?? 0.0);
|
||||
if (_willClose(scrollVelocity)) {
|
||||
_viewer.setShowingDetails(false);
|
||||
}
|
||||
_drag?.end(details);
|
||||
_drag = null;
|
||||
case _DragIntent.dismiss:
|
||||
if (_shouldPopOnDrag) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
_viewController?.animateMultiple(
|
||||
position: _initialPhotoViewState.position,
|
||||
scale: _viewController?.initialScale ?? _initialPhotoViewState.scale,
|
||||
rotation: _initialPhotoViewState.rotation,
|
||||
);
|
||||
_viewer.setOpacity(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragStart(
|
||||
BuildContext context,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerBase controller,
|
||||
PhotoViewScaleStateController scaleStateController,
|
||||
) {
|
||||
_viewController = controller;
|
||||
if (!_showingDetails && _isZoomed) return;
|
||||
_beginDrag(details);
|
||||
}
|
||||
|
||||
void _onDragUpdate(BuildContext context, DragUpdateDetails details, PhotoViewControllerValue _) =>
|
||||
_updateDrag(details);
|
||||
|
||||
void _onDragEnd(BuildContext context, DragEndDetails details, PhotoViewControllerValue _) => _endDrag(details);
|
||||
|
||||
void _onDragCancel() => _endDrag(DragEndDetails(primaryVelocity: 0.0));
|
||||
|
||||
void _handleDragDown(BuildContext context, Offset delta) {
|
||||
const dragRatio = 0.2;
|
||||
const popThreshold = 75.0;
|
||||
|
||||
_shouldPopOnDrag = delta.dy > popThreshold;
|
||||
|
||||
final distance = delta.dy.abs();
|
||||
|
||||
final maxScaleDistance = context.height * 0.5;
|
||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||
final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale;
|
||||
final updatedScale = initialScale != null ? initialScale * (1.0 - scaleReduction) : null;
|
||||
|
||||
final opacity = 1.0 - (scaleReduction / dragRatio);
|
||||
|
||||
_viewController?.updateMultiple(position: _initialPhotoViewState.position + delta, scale: updatedScale);
|
||||
_viewer.setOpacity(opacity);
|
||||
}
|
||||
|
||||
void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) {
|
||||
if (!_showingDetails && _dragStart == null) _viewer.toggleControls();
|
||||
}
|
||||
|
||||
void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) =>
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
_isZoomed = switch (scaleState) {
|
||||
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
|
||||
_ => false,
|
||||
};
|
||||
_viewer.setZoomed(_isZoomed);
|
||||
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
if (_dragStart == null) _viewer.setControls(false);
|
||||
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_showingDetails) _viewer.setControls(true);
|
||||
}
|
||||
|
||||
void _listenForScaleBoundaries(PhotoViewControllerBase? controller) {
|
||||
_scaleBoundarySub?.cancel();
|
||||
_scaleBoundarySub = null;
|
||||
if (controller == null || controller.scaleBoundaries != null) return;
|
||||
_scaleBoundarySub = controller.outputStateStream.listen((_) {
|
||||
if (controller.scaleBoundaries != null) {
|
||||
_scaleBoundarySub?.cancel();
|
||||
_scaleBoundarySub = null;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
double _getImageHeight(double maxWidth, double maxHeight, BaseAsset? asset) {
|
||||
final sb = _viewController?.scaleBoundaries;
|
||||
if (sb != null) return sb.childSize.height * sb.initialScale;
|
||||
|
||||
if (asset == null || asset.width == null || asset.height == null) return maxHeight;
|
||||
|
||||
final r = asset.width! / asset.height!;
|
||||
return math.min(maxWidth / r, maxHeight);
|
||||
}
|
||||
|
||||
void _onPageBuild(PhotoViewControllerBase controller) {
|
||||
_viewController = controller;
|
||||
_listenForScaleBoundaries(controller);
|
||||
}
|
||||
|
||||
Widget _buildPhotoView(
|
||||
BaseAsset displayAsset,
|
||||
BaseAsset asset, {
|
||||
required bool isCurrentPage,
|
||||
required bool showingDetails,
|
||||
required bool isPlayingMotionVideo,
|
||||
required BoxDecoration backgroundDecoration,
|
||||
}) {
|
||||
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
|
||||
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
final size = context.sizeData;
|
||||
return PhotoView(
|
||||
key: ValueKey(displayAsset.heroTag),
|
||||
index: widget.index,
|
||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
||||
heroAttributes: heroAttributes,
|
||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
enablePanAlways: true,
|
||||
disableScaleGestures: showingDetails,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
|
||||
errorBuilder: (_, __, ___) => SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
heroAttributes: heroAttributes,
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: true,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
enablePanAlways: true,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
child: SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
child: NativeVideoViewer(
|
||||
key: ValueKey(displayAsset.heroTag),
|
||||
asset: displayAsset,
|
||||
image: Image(
|
||||
key: ValueKey(displayAsset),
|
||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||
fit: BoxFit.contain,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
|
||||
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
if (asset == null) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
BaseAsset displayAsset = asset;
|
||||
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
|
||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||
displayAsset = stackChildren.elementAt(stackIndex);
|
||||
}
|
||||
|
||||
final viewportWidth = MediaQuery.widthOf(context);
|
||||
final viewportHeight = MediaQuery.heightOf(context);
|
||||
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
||||
|
||||
final detailsOffset = (viewportHeight + imageHeight - kMinInteractiveDimension) / 2;
|
||||
final snapTarget = viewportHeight / 3;
|
||||
|
||||
_snapOffset = detailsOffset - snapTarget;
|
||||
|
||||
if (_proxyScrollController.hasClients) {
|
||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||
}
|
||||
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
|
||||
currentAssetExifProvider.overrideWith((ref) {
|
||||
final a = ref.watch(currentAssetNotifier);
|
||||
if (a == null) return Future.value(null);
|
||||
return ref.watch(assetServiceProvider).getExif(a);
|
||||
}),
|
||||
],
|
||||
child: Stack(
|
||||
children: [
|
||||
Offstage(
|
||||
child: SingleChildScrollView(
|
||||
controller: _proxyScrollController,
|
||||
physics: const SnapScrollPhysics(),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
child: _buildPhotoView(
|
||||
displayAsset,
|
||||
asset,
|
||||
isCurrentPage: currentHeroTag == asset.heroTag,
|
||||
showingDetails: _showingDetails,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: detailsOffset),
|
||||
GestureDetector(
|
||||
onVerticalDragStart: _beginDrag,
|
||||
onVerticalDragUpdate: _updateDrag,
|
||||
onVerticalDragEnd: _endDrag,
|
||||
onVerticalDragCancel: _onDragCancel,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showingDetails ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: AssetDetails(minHeight: viewportHeight - snapTarget),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
|
||||
class AssetPreloader {
|
||||
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
||||
|
||||
final TimelineService timelineService;
|
||||
final bool Function() mounted;
|
||||
|
||||
Timer? _timer;
|
||||
ImageStream? _prevStream;
|
||||
ImageStream? _nextStream;
|
||||
|
||||
AssetPreloader({required this.timelineService, required this.mounted});
|
||||
|
||||
void preload(int index, Size size) {
|
||||
unawaited(timelineService.preloadAssets(index));
|
||||
_timer?.cancel();
|
||||
_timer = Timer(Durations.medium4, () async {
|
||||
if (!mounted()) return;
|
||||
final (prev, next) = await (
|
||||
timelineService.getAssetAsync(index - 1),
|
||||
timelineService.getAssetAsync(index + 1),
|
||||
).wait;
|
||||
if (!mounted()) return;
|
||||
_prevStream?.removeListener(_dummyListener);
|
||||
_nextStream?.removeListener(_dummyListener);
|
||||
_prevStream = prev != null ? _resolveImage(prev, size) : null;
|
||||
_nextStream = next != null ? _resolveImage(next, size) : null;
|
||||
});
|
||||
}
|
||||
|
||||
ImageStream _resolveImage(BaseAsset asset, Size size) {
|
||||
return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_prevStream?.removeListener(_dummyListener);
|
||||
_nextStream?.removeListener(_dummyListener);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
|
||||
class AssetStackRow extends ConsumerWidget {
|
||||
const AssetStackRow({super.key});
|
||||
@@ -21,11 +21,17 @@ class AssetStackRow extends ConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _StackList(stack: stackChildren);
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity / 255,
|
||||
duration: Durations.short2,
|
||||
child: _StackList(stack: stackChildren),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,19 +14,27 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AssetViewerPage extends StatelessWidget {
|
||||
@@ -71,6 +79,10 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
void changeAsset(WidgetRef ref, BaseAsset asset) {
|
||||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
// Always holds the current asset from the timeline
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
@@ -82,57 +94,140 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
if (asset.isVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const double _kBottomSheetMinimumExtent = 0.4;
|
||||
const double _kBottomSheetSnapExtent = 0.67;
|
||||
|
||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
late final _pageController = PageController(initialPage: widget.initialIndex);
|
||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
||||
late PageController pageController;
|
||||
late DraggableScrollableController bottomSheetController;
|
||||
PersistentBottomSheetController? sheetCloseController;
|
||||
// PhotoViewGallery takes care of disposing it's controllers
|
||||
PhotoViewControllerBase? viewController;
|
||||
StreamSubscription? reloadSubscription;
|
||||
|
||||
late final int heroOffset;
|
||||
late PhotoViewControllerValue initialPhotoViewState;
|
||||
bool? hasDraggedDown;
|
||||
bool isSnapping = false;
|
||||
bool blockGestures = false;
|
||||
bool dragInProgress = false;
|
||||
bool shouldPopOnDrag = false;
|
||||
bool assetReloadRequested = false;
|
||||
double previousExtent = _kBottomSheetMinimumExtent;
|
||||
Offset dragDownPosition = Offset.zero;
|
||||
int totalAssets = 0;
|
||||
int stackIndex = 0;
|
||||
BuildContext? scaffoldContext;
|
||||
Map<String, GlobalKey> videoPlayerKeys = {};
|
||||
|
||||
// Delayed operations that should be cancelled on disposal
|
||||
final List<Timer> _delayedOperations = [];
|
||||
|
||||
ImageStream? _prevPreCacheStream;
|
||||
ImageStream? _nextPreCacheStream;
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
|
||||
bool _assetReloadRequested = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
|
||||
_reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
|
||||
pageController = PageController(initialPage: widget.initialIndex);
|
||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||
bottomSheetController = DraggableScrollableController();
|
||||
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
||||
reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset != null) {
|
||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
}
|
||||
if (ref.read(assetViewerProvider).showingControls) {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
} else {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_preloader.dispose();
|
||||
_reloadSubscription?.cancel();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
|
||||
pageController.dispose();
|
||||
bottomSheetController.dispose();
|
||||
_cancelTimers();
|
||||
reloadSubscription?.cancel();
|
||||
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
|
||||
_stackChildrenKeepAlive?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onAssetInit(Duration timeStamp) {
|
||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
|
||||
Color get backgroundColor {
|
||||
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
return Colors.black.withAlpha(opacity);
|
||||
}
|
||||
|
||||
void _cancelTimers() {
|
||||
for (final timer in _delayedOperations) {
|
||||
timer.cancel();
|
||||
}
|
||||
_delayedOperations.clear();
|
||||
}
|
||||
|
||||
double _getVerticalOffsetForBottomSheet(double extent) =>
|
||||
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
||||
|
||||
ImageStream _precacheImage(BaseAsset asset) {
|
||||
final provider = getFullImageProvider(asset, size: context.sizeData);
|
||||
return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener);
|
||||
}
|
||||
|
||||
void _precacheAssets(int index) {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
unawaited(timelineService.preCacheAssets(index));
|
||||
_cancelTimers();
|
||||
// This will trigger the pre-caching of adjacent assets ensuring
|
||||
// that they are ready when the user navigates to them.
|
||||
final timer = Timer(Durations.medium4, () async {
|
||||
// Check if widget is still mounted before proceeding
|
||||
if (!mounted) return;
|
||||
|
||||
final (prevAsset, nextAsset) = await (
|
||||
timelineService.getAssetAsync(index - 1),
|
||||
timelineService.getAssetAsync(index + 1),
|
||||
).wait;
|
||||
if (!mounted) return;
|
||||
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||
_prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null;
|
||||
_nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null;
|
||||
});
|
||||
_delayedOperations.add(timer);
|
||||
}
|
||||
|
||||
void _onAssetInit(Duration _) {
|
||||
_precacheAssets(widget.initialIndex);
|
||||
_handleCasting();
|
||||
}
|
||||
|
||||
void _onAssetChanged(int index) async {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = await timelineService.getAssetAsync(index);
|
||||
if (asset == null) return;
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
AssetViewer._setAsset(ref, asset);
|
||||
_preloader.preload(index, context.sizeData);
|
||||
widget.changeAsset(ref, asset);
|
||||
_precacheAssets(index);
|
||||
_handleCasting();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
@@ -143,106 +238,460 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
|
||||
// hide any casting snackbars if they exist
|
||||
context.scaffoldMessenger.hideCurrentSnackBar();
|
||||
|
||||
// send image to casting if the server has it
|
||||
if (asset is RemoteAsset) {
|
||||
context.scaffoldMessenger.hideCurrentSnackBar();
|
||||
ref.read(castProvider.notifier).loadMedia(asset, false);
|
||||
} else {
|
||||
// casting cannot show local assets
|
||||
context.scaffoldMessenger.clearSnackBars();
|
||||
|
||||
if (ref.read(castProvider).isCasting) {
|
||||
ref.read(castProvider.notifier).stop();
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Text(
|
||||
"local_asset_cast_failed".tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageBuild(PhotoViewControllerBase controller) {
|
||||
viewController ??= controller;
|
||||
if (showingBottomSheet && bottomSheetController.isAttached) {
|
||||
final verticalOffset =
|
||||
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
||||
controller.position = Offset(0, -verticalOffset);
|
||||
// Apply the zoom effect when the bottom sheet is showing
|
||||
controller.scale = (controller.scale ?? 1.0) + 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
|
||||
_onAssetChanged(index);
|
||||
viewController = controller;
|
||||
}
|
||||
|
||||
void _onDragStart(
|
||||
_,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerBase controller,
|
||||
PhotoViewScaleStateController scaleStateController,
|
||||
) {
|
||||
viewController = controller;
|
||||
dragDownPosition = details.localPosition;
|
||||
initialPhotoViewState = controller.value;
|
||||
final isZoomed =
|
||||
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleStateController.scaleState == PhotoViewScaleState.covering;
|
||||
if (!showingBottomSheet && isZoomed) {
|
||||
blockGestures = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragEnd(BuildContext ctx, _, __) {
|
||||
dragInProgress = false;
|
||||
|
||||
if (shouldPopOnDrag) {
|
||||
// Dismiss immediately without state updates to avoid rebuilds
|
||||
ctx.maybePop();
|
||||
return;
|
||||
}
|
||||
|
||||
context.scaffoldMessenger.clearSnackBars();
|
||||
ref.read(castProvider.notifier).stop();
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Text(
|
||||
"local_asset_cast_failed".tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
// Do not reset the state if the bottom sheet is showing
|
||||
if (showingBottomSheet) {
|
||||
_snapBottomSheet();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the gestures are blocked, do not reset the state
|
||||
if (blockGestures) {
|
||||
blockGestures = false;
|
||||
return;
|
||||
}
|
||||
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
viewController?.animateMultiple(
|
||||
position: initialPhotoViewState.position,
|
||||
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
|
||||
rotation: initialPhotoViewState.rotation,
|
||||
);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(255);
|
||||
}
|
||||
|
||||
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
|
||||
if (blockGestures) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragInProgress = true;
|
||||
final delta = details.localPosition - dragDownPosition;
|
||||
hasDraggedDown ??= delta.dy > 0;
|
||||
if (!hasDraggedDown! || showingBottomSheet) {
|
||||
_handleDragUp(ctx, delta);
|
||||
return;
|
||||
}
|
||||
|
||||
_handleDragDown(ctx, delta);
|
||||
}
|
||||
|
||||
void _handleDragUp(BuildContext ctx, Offset delta) {
|
||||
const double openThreshold = 50;
|
||||
|
||||
final position = initialPhotoViewState.position + Offset(0, delta.dy);
|
||||
final distanceToOrigin = position.distance;
|
||||
|
||||
viewController?.updateMultiple(position: position);
|
||||
// Moves the bottom sheet when the asset is being dragged up
|
||||
if (showingBottomSheet && bottomSheetController.isAttached) {
|
||||
final centre = (ctx.height * _kBottomSheetMinimumExtent);
|
||||
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
|
||||
}
|
||||
|
||||
if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) {
|
||||
_openBottomSheet(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragDown(BuildContext ctx, Offset delta) {
|
||||
const double dragRatio = 0.2;
|
||||
const double popThreshold = 75;
|
||||
|
||||
final distance = delta.distance;
|
||||
shouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
|
||||
|
||||
final maxScaleDistance = ctx.height * 0.5;
|
||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||
double? updatedScale;
|
||||
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
|
||||
if (initialScale != null) {
|
||||
updatedScale = initialScale * (1.0 - scaleReduction);
|
||||
}
|
||||
|
||||
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||
|
||||
viewController?.updateMultiple(position: initialPhotoViewState.position + delta, scale: updatedScale);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity);
|
||||
}
|
||||
|
||||
void _onTapDown(_, __, ___) {
|
||||
if (!showingBottomSheet) {
|
||||
ref.read(assetViewerProvider.notifier).toggleControls();
|
||||
}
|
||||
}
|
||||
|
||||
bool _onNotification(Notification delta) {
|
||||
if (delta is DraggableScrollableNotification) {
|
||||
_handleDraggableNotification(delta);
|
||||
}
|
||||
|
||||
// Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after
|
||||
// the isSnapping guard is to prevent the notification from recursively handling the
|
||||
// notification, eventually resulting in a heap overflow
|
||||
if (!isSnapping && delta is ScrollEndNotification) {
|
||||
_snapBottomSheet();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _handleDraggableNotification(DraggableScrollableNotification delta) {
|
||||
final currentExtent = delta.extent;
|
||||
final isDraggingDown = currentExtent < previousExtent;
|
||||
previousExtent = currentExtent;
|
||||
// Closes the bottom sheet if the user is dragging down
|
||||
if (isDraggingDown && delta.extent < 0.67) {
|
||||
if (dragInProgress) {
|
||||
blockGestures = true;
|
||||
}
|
||||
// Jump to a lower position before starting close animation to prevent glitch
|
||||
if (bottomSheetController.isAttached) {
|
||||
bottomSheetController.jumpTo(0.67);
|
||||
}
|
||||
sheetCloseController?.close();
|
||||
}
|
||||
|
||||
// If the asset is being dragged down, we do not want to update the asset position again
|
||||
if (dragInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent);
|
||||
// Moves the asset when the bottom sheet is being dragged
|
||||
if (verticalOffset > 0) {
|
||||
viewController?.position = Offset(0, -verticalOffset);
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
switch (event) {
|
||||
case TimelineReloadEvent():
|
||||
_onTimelineReloadEvent();
|
||||
case ViewerReloadAssetEvent():
|
||||
_assetReloadRequested = true;
|
||||
default:
|
||||
if (event is TimelineReloadEvent) {
|
||||
_onTimelineReloadEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is ViewerReloadAssetEvent) {
|
||||
assetReloadRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is ViewerOpenBottomSheetEvent) {
|
||||
final extent = _kBottomSheetMinimumExtent + 0.3;
|
||||
_openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode);
|
||||
final offset = _getVerticalOffsetForBottomSheet(extent);
|
||||
viewController?.position = Offset(0, -offset);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _onTimelineReloadEvent() {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final totalAssets = timelineService.totalAssets;
|
||||
totalAssets = timelineService.totalAssets;
|
||||
|
||||
if (totalAssets == 0) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
|
||||
var index = _pageController.page?.round() ?? 0;
|
||||
var index = pageController.page?.round() ?? 0;
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
if (currentAsset != null) {
|
||||
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
||||
if (newIndex != null && newIndex != index) {
|
||||
index = newIndex;
|
||||
_pageController.jumpToPage(index);
|
||||
pageController.jumpToPage(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (index >= totalAssets) {
|
||||
index = totalAssets - 1;
|
||||
_pageController.jumpToPage(index);
|
||||
pageController.jumpToPage(index);
|
||||
}
|
||||
|
||||
if (_assetReloadRequested) {
|
||||
_assetReloadRequested = false;
|
||||
if (assetReloadRequested) {
|
||||
assetReloadRequested = false;
|
||||
_onAssetReloadEvent(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _onAssetReloadEvent(int index) async {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
if (newAsset == null) return;
|
||||
|
||||
if (newAsset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
// Do not reload / close the bottom sheet if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not reload if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||
setState(() {
|
||||
_onAssetChanged(pageController.page!.round());
|
||||
sheetCloseController?.close();
|
||||
});
|
||||
}
|
||||
|
||||
_onAssetChanged(index);
|
||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||
previousExtent = _kBottomSheetMinimumExtent;
|
||||
sheetCloseController = showBottomSheet(
|
||||
context: ctx,
|
||||
sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2),
|
||||
constraints: const BoxConstraints(maxWidth: double.infinity),
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))),
|
||||
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
|
||||
builder: (_) {
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: _onNotification,
|
||||
child: activitiesMode
|
||||
? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent)
|
||||
: AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent),
|
||||
);
|
||||
},
|
||||
);
|
||||
sheetCloseController?.closed.then((_) => _handleSheetClose());
|
||||
}
|
||||
|
||||
void _handleSheetClose() {
|
||||
viewController?.animateMultiple(position: Offset.zero);
|
||||
viewController?.updateMultiple(scale: viewController?.initialScale);
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
|
||||
sheetCloseController = null;
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
}
|
||||
|
||||
void _snapBottomSheet() {
|
||||
if (!bottomSheetController.isAttached ||
|
||||
bottomSheetController.size > _kBottomSheetSnapExtent ||
|
||||
bottomSheetController.size < 0.4) {
|
||||
return;
|
||||
}
|
||||
isSnapping = true;
|
||||
bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
if (!dragInProgress) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showingBottomSheet) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||
}
|
||||
}
|
||||
|
||||
void _onLongPress(_, __, ___) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||
scaffoldContext ??= ctx;
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = timelineService.getAssetSafe(index);
|
||||
|
||||
// If asset is not available in buffer, return a placeholder
|
||||
if (asset == null) {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'),
|
||||
child: Container(
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
color: backgroundColor,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BaseAsset displayAsset = asset;
|
||||
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
|
||||
}
|
||||
|
||||
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
return _imageBuilder(ctx, displayAsset);
|
||||
}
|
||||
|
||||
return _videoBuilder(ctx, displayAsset);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
|
||||
final size = ctx.sizeData;
|
||||
return PhotoViewGalleryPageOptions(
|
||||
key: ValueKey(asset.heroTag),
|
||||
imageProvider: getFullImageProvider(asset, size: size),
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
disableScaleGestures: showingBottomSheet,
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onTapDown: _onTapDown,
|
||||
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GlobalKey _getVideoPlayerKey(String id) {
|
||||
videoPlayerKeys.putIfAbsent(id, () => GlobalKey());
|
||||
return videoPlayerKeys[id]!;
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onTapDown: _onTapDown,
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: true,
|
||||
child: SizedBox(
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
child: NativeVideoViewer(
|
||||
key: _getVideoPlayerKey(asset.heroTag),
|
||||
asset: asset,
|
||||
image: Image(
|
||||
key: ValueKey(asset),
|
||||
image: getFullImageProvider(asset, size: ctx.sizeData),
|
||||
fit: BoxFit.contain,
|
||||
height: ctx.height,
|
||||
width: ctx.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPop<T>(bool didPop, T? result) {
|
||||
ref.read(currentAssetNotifier.notifier).dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Rebuild the widget when the asset viewer state changes
|
||||
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
|
||||
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
ref.watch(isPlayingMotionVideoProvider);
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isZoomed = ref.watch(assetViewerProvider.select((s) => s.isZoomed));
|
||||
final backgroundColor = showingDetails
|
||||
? context.colorScheme.surface
|
||||
: Colors.black.withValues(alpha: ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)));
|
||||
|
||||
// Listen for casting changes and send initial asset to the cast provider
|
||||
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) {
|
||||
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async {
|
||||
if (!isCasting) return;
|
||||
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleCasting();
|
||||
});
|
||||
});
|
||||
|
||||
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
|
||||
final (controls, details) = state;
|
||||
final mode = !controls || (CurrentPlatform.isIOS && details)
|
||||
? SystemUiMode.immersiveSticky
|
||||
: SystemUiMode.edgeToEdge;
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
|
||||
// Listen for control visibility changes and change system UI mode accordingly
|
||||
ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async {
|
||||
if (showingControls) {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
} else {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
|
||||
}
|
||||
});
|
||||
|
||||
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
|
||||
// Issue: https://github.com/flutter/flutter/issues/109037
|
||||
// TODO: Add a custom scrum builder once the fix lands on stable
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
|
||||
onPopInvokedWithResult: _onPop,
|
||||
child: Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
@@ -256,29 +705,33 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
||||
onPageChanged: (index) => _onAssetChanged(index),
|
||||
itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset),
|
||||
),
|
||||
PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
pageController: pageController,
|
||||
scrollPhysics: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
||||
itemCount: totalAssets,
|
||||
onPageChanged: _onPageChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
builder: _assetBuilder,
|
||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||
enablePanAlways: true,
|
||||
),
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
if (!showingBottomSheet)
|
||||
const Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [AssetStackRow(), ViewerBottomBar()],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,35 +3,31 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class AssetViewerState {
|
||||
final double backgroundOpacity;
|
||||
final bool showingDetails;
|
||||
final int backgroundOpacity;
|
||||
final bool showingBottomSheet;
|
||||
final bool showingControls;
|
||||
final bool isZoomed;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
|
||||
const AssetViewerState({
|
||||
this.backgroundOpacity = 1.0,
|
||||
this.showingDetails = false,
|
||||
this.backgroundOpacity = 255,
|
||||
this.showingBottomSheet = false,
|
||||
this.showingControls = true,
|
||||
this.isZoomed = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
});
|
||||
|
||||
AssetViewerState copyWith({
|
||||
double? backgroundOpacity,
|
||||
bool? showingDetails,
|
||||
int? backgroundOpacity,
|
||||
bool? showingBottomSheet,
|
||||
bool? showingControls,
|
||||
bool? isZoomed,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
}) {
|
||||
return AssetViewerState(
|
||||
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
||||
showingDetails: showingDetails ?? this.showingDetails,
|
||||
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
|
||||
showingControls: showingControls ?? this.showingControls,
|
||||
isZoomed: isZoomed ?? this.isZoomed,
|
||||
currentAsset: currentAsset ?? this.currentAsset,
|
||||
stackIndex: stackIndex ?? this.stackIndex,
|
||||
);
|
||||
@@ -39,7 +35,7 @@ class AssetViewerState {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -48,9 +44,8 @@ class AssetViewerState {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is AssetViewerState &&
|
||||
other.backgroundOpacity == backgroundOpacity &&
|
||||
other.showingDetails == showingDetails &&
|
||||
other.showingBottomSheet == showingBottomSheet &&
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
}
|
||||
@@ -58,9 +53,8 @@ class AssetViewerState {
|
||||
@override
|
||||
int get hashCode =>
|
||||
backgroundOpacity.hashCode ^
|
||||
showingDetails.hashCode ^
|
||||
showingBottomSheet.hashCode ^
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
}
|
||||
@@ -82,18 +76,18 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
}
|
||||
|
||||
void setOpacity(double opacity) {
|
||||
void setOpacity(int opacity) {
|
||||
if (opacity == state.backgroundOpacity) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls);
|
||||
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls);
|
||||
}
|
||||
|
||||
void setShowingDetails(bool showing) {
|
||||
if (showing == state.showingDetails) {
|
||||
void setBottomSheet(bool showing) {
|
||||
if (showing == state.showingBottomSheet) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls);
|
||||
if (showing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
@@ -110,13 +104,6 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
state = state.copyWith(showingControls: !state.showingControls);
|
||||
}
|
||||
|
||||
void setZoomed(bool isZoomed) {
|
||||
if (isZoomed == state.isZoomed) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isZoomed: isZoomed);
|
||||
}
|
||||
|
||||
void setStackIndex(int index) {
|
||||
if (index == state.stackIndex) {
|
||||
return;
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -29,9 +29,15 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0;
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
@@ -50,30 +56,37 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
],
|
||||
];
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: showingDetails
|
||||
? const SizedBox.shrink()
|
||||
: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
textTheme: context.themeData.textTheme.copyWith(
|
||||
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity / 255,
|
||||
duration: Durations.short2,
|
||||
child: AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: isSheetOpen
|
||||
? const SizedBox.shrink()
|
||||
: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
textTheme: context.themeData.textTheme.copyWith(
|
||||
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class AssetDetailBottomSheet extends ConsumerWidget {
|
||||
final DraggableScrollableController? controller;
|
||||
final double initialChildSize;
|
||||
|
||||
const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
actions: [],
|
||||
slivers: const [_AssetDetailBottomSheet()],
|
||||
controller: controller,
|
||||
initialChildSize: initialChildSize,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: 0.88,
|
||||
expand: false,
|
||||
shouldCloseOnMinExtent: false,
|
||||
resizeOnScroll: false,
|
||||
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const _AssetDetailBottomSheet();
|
||||
|
||||
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
||||
DateTime dateTime = asset.createdAt.toLocal();
|
||||
Duration timeZoneOffset = dateTime.timeZoneOffset;
|
||||
|
||||
// Use EXIF timezone information if available (matching web app behavior)
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
(dateTime, timeZoneOffset) = applyTimezoneOffset(
|
||||
dateTime: exifInfo!.dateTimeOriginal!,
|
||||
timeZone: exifInfo.timeZone,
|
||||
);
|
||||
}
|
||||
|
||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
|
||||
return '$date$_kSeparator$time $timezone';
|
||||
}
|
||||
|
||||
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
||||
final height = asset.height;
|
||||
final width = asset.width;
|
||||
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
|
||||
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
|
||||
|
||||
return switch ((fileSize, resolution)) {
|
||||
(null, null) => '',
|
||||
(String fileSize, null) => fileSize,
|
||||
(null, String resolution) => resolution,
|
||||
(String fileSize, String resolution) => '$fileSize$_kSeparator$resolution',
|
||||
};
|
||||
}
|
||||
|
||||
String? _getCameraInfoTitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch ((exifInfo.make, exifInfo.model)) {
|
||||
(null, null) => null,
|
||||
(String make, null) => make,
|
||||
(null, String model) => model,
|
||||
(String make, String model) => '$make $model',
|
||||
};
|
||||
}
|
||||
|
||||
String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) {
|
||||
return null;
|
||||
}
|
||||
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
||||
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
||||
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
|
||||
String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) {
|
||||
return null;
|
||||
}
|
||||
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
|
||||
Future<void> _editDateTime(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
|
||||
}
|
||||
|
||||
Widget _buildAppearsInList(WidgetRef ref, BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!asset.hasRemote) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
String? remoteAssetId;
|
||||
if (asset is RemoteAsset) {
|
||||
remoteAssetId = asset.id;
|
||||
} else if (asset is LocalAsset) {
|
||||
remoteAssetId = asset.remoteAssetId;
|
||||
}
|
||||
|
||||
if (remoteAssetId == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
|
||||
|
||||
return assetAlbums.when(
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
albums.sortBy((a) => a.name);
|
||||
|
||||
return Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (albums.isNotEmpty)
|
||||
SheetTile(
|
||||
title: 'appears_in'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: albums.map((album) {
|
||||
final isOwner = album.ownerId == userId;
|
||||
return AlbumTile(
|
||||
album: album,
|
||||
isOwner: isOwner,
|
||||
onAlbumSelected: (album) async {
|
||||
ref.invalidate(assetViewerProvider);
|
||||
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
// Build file info tile based on asset type
|
||||
Widget buildFileInfoTile() {
|
||||
if (asset is LocalAsset) {
|
||||
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
|
||||
return FutureBuilder<String?>(
|
||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
||||
builder: (context, snapshot) {
|
||||
final displayName = snapshot.data ?? asset.name;
|
||||
return SheetTile(
|
||||
title: displayName,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(
|
||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
||||
size: 24,
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// For remote assets, use the name directly
|
||||
return SheetTile(
|
||||
title: asset.name,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(
|
||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
||||
size: 24,
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return SliverList.list(
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
||||
),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
||||
const SheetPeopleDetails(),
|
||||
const SheetLocationDetails(),
|
||||
// Details header
|
||||
SheetTile(
|
||||
title: 'details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
// File info
|
||||
buildFileInfoTile(),
|
||||
// Camera info
|
||||
if (cameraTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
// Lens info
|
||||
if (lensTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
// Rating bar
|
||||
if (isRatingEnabled) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'rating'.t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
RatingBar(
|
||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||
filledColor: context.themeData.colorScheme.primary,
|
||||
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
|
||||
itemSize: 40,
|
||||
onRatingUpdate: (rating) async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
|
||||
},
|
||||
onClearRating: () async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Appears in (Albums)
|
||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
||||
// padding at the bottom to avoid cut-off
|
||||
const SizedBox(height: 60),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||
final ExifInfo exif;
|
||||
final bool isEditable;
|
||||
|
||||
const _SheetAssetDescription({required this.exif, this.isEditable = true});
|
||||
|
||||
@override
|
||||
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
||||
}
|
||||
|
||||
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
|
||||
late TextEditingController _controller;
|
||||
final _descriptionFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.exif.description ?? '');
|
||||
}
|
||||
|
||||
Future<void> saveDescription(String? previousDescription) async {
|
||||
final newDescription = _controller.text.trim();
|
||||
|
||||
if (newDescription == previousDescription) {
|
||||
_descriptionFocus.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
|
||||
|
||||
if (!editAction.success) {
|
||||
_controller.text = previousDescription ?? '';
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'exif_bottom_sheet_description_error'.t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
_descriptionFocus.unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch the current asset EXIF provider to get updates
|
||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
// Update controller text when EXIF data changes
|
||||
final currentDescription = currentExifInfo?.description ?? '';
|
||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||
context: context,
|
||||
);
|
||||
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
||||
_controller.text = currentDescription;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.isEditable,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.multiline,
|
||||
focusNode: _descriptionFocus,
|
||||
maxLines: null, // makes it grow as text is added
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,18 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class LocationDetails extends ConsumerStatefulWidget {
|
||||
const LocationDetails({super.key});
|
||||
class SheetLocationDetails extends ConsumerStatefulWidget {
|
||||
const SheetLocationDetails({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _LocationDetailsState();
|
||||
ConsumerState createState() => _SheetLocationDetailsState();
|
||||
}
|
||||
|
||||
class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
MapLibreMapController? _mapController;
|
||||
|
||||
String? _getLocationName(ExifInfo? exifInfo) {
|
||||
@@ -42,6 +42,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
|
||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||
final currentExif = current.valueOrNull;
|
||||
|
||||
if (currentExif != null && currentExif.hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||
}
|
||||
@@ -7,7 +7,7 @@ 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/presentation/widgets/people/person_edit_name_modal.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
@@ -15,14 +15,14 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/utils/people.utils.dart';
|
||||
|
||||
class PeopleDetails extends ConsumerStatefulWidget {
|
||||
const PeopleDetails({super.key});
|
||||
class SheetPeopleDetails extends ConsumerStatefulWidget {
|
||||
const SheetPeopleDetails({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _PeopleDetailsState();
|
||||
ConsumerState createState() => _SheetPeopleDetailsState();
|
||||
}
|
||||
|
||||
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||
class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
@@ -65,7 +65,7 @@ class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final person in people)
|
||||
_Avatar(
|
||||
_PeopleAvatar(
|
||||
person: person,
|
||||
assetFileCreatedAt: asset.createdAt,
|
||||
onTap: () {
|
||||
@@ -97,14 +97,14 @@ class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
class _PeopleAvatar extends StatelessWidget {
|
||||
final DriftPerson person;
|
||||
final DateTime assetFileCreatedAt;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onNameTap;
|
||||
final double imageSize = 96;
|
||||
|
||||
const _Avatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
|
||||
const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -3,15 +3,16 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/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/routing/router.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
@@ -34,8 +35,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
||||
@@ -43,7 +44,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
}
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
opacity = 0;
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
@@ -54,13 +55,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
onPressed: () {
|
||||
context.router.push(
|
||||
DriftActivitiesRoute(
|
||||
album: album,
|
||||
assetId: asset is RemoteAsset ? asset.id : null,
|
||||
assetName: asset.name,
|
||||
),
|
||||
);
|
||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
|
||||
},
|
||||
),
|
||||
|
||||
@@ -75,17 +70,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 1.0,
|
||||
ignoring: opacity < 255,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
opacity: opacity / 255,
|
||||
duration: Durations.short2,
|
||||
child: AppBar(
|
||||
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
|
||||
backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125),
|
||||
leading: const _AppBarBackButton(),
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
shape: const Border(),
|
||||
actions: showingDetails || isReadonlyModeEnabled
|
||||
actions: isShowingSheet || isReadonlyModeEnabled
|
||||
? null
|
||||
: isInLockedView
|
||||
? lockedViewActions
|
||||
@@ -104,9 +99,9 @@ class _AppBarBackButton extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0),
|
||||
@@ -117,7 +112,7 @@ class _AppBarBackButton extends ConsumerWidget {
|
||||
iconSize: 22,
|
||||
iconColor: foregroundColor,
|
||||
padding: EdgeInsets.zero,
|
||||
elevation: showingDetails ? 4 : 0,
|
||||
elevation: isShowingSheet ? 4 : 0,
|
||||
),
|
||||
onPressed: context.maybePop,
|
||||
child: const Icon(Icons.arrow_back_rounded),
|
||||
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
@@ -205,7 +205,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
|
||||
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
|
||||
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
@@ -19,8 +19,8 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
||||
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
if (showBottomSheet) {
|
||||
showControls = false;
|
||||
}
|
||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||
|
||||
class ViewerBottomAppBar extends ConsumerWidget {
|
||||
const ViewerBottomAppBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 1.0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [AssetStackRow(), ViewerBottomBar()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
@@ -29,38 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
|
||||
class _TimelineRestorationState extends ChangeNotifier {
|
||||
int? _restoreAssetIndex;
|
||||
bool _shouldRestoreAssetPosition = false;
|
||||
|
||||
int? get restoreAssetIndex => _restoreAssetIndex;
|
||||
bool get shouldRestoreAssetPosition => _shouldRestoreAssetPosition;
|
||||
|
||||
void setRestoreAssetIndex(int? index) {
|
||||
_restoreAssetIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setShouldRestoreAssetPosition(bool should) {
|
||||
_shouldRestoreAssetPosition = should;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearRestoreAssetIndex() {
|
||||
_restoreAssetIndex = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineRestorationProvider extends InheritedNotifier<_TimelineRestorationState> {
|
||||
const _TimelineRestorationProvider({required super.notifier, required super.child});
|
||||
|
||||
static _TimelineRestorationState of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<_TimelineRestorationProvider>()!.notifier!;
|
||||
}
|
||||
}
|
||||
|
||||
class Timeline extends StatefulWidget {
|
||||
class Timeline extends ConsumerWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
@@ -89,66 +58,36 @@ class Timeline extends StatefulWidget {
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<Timeline> createState() => _TimelineState();
|
||||
}
|
||||
|
||||
class _TimelineState extends State<Timeline> {
|
||||
double? _lastWidth;
|
||||
late final _TimelineRestorationState _restorationState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_restorationState = _TimelineRestorationState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_restorationState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
if (_lastWidth != null && _lastWidth != constraints.maxWidth) {
|
||||
_restorationState.setShouldRestoreAssetPosition(true);
|
||||
}
|
||||
_lastWidth = constraints.maxWidth;
|
||||
return _TimelineRestorationProvider(
|
||||
notifier: _restorationState,
|
||||
child: ProviderScope(
|
||||
key: ValueKey(_lastWidth),
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
|
||||
showStorageIndicator: widget.showStorageIndicator,
|
||||
withStack: widget.withStack,
|
||||
groupBy: widget.groupBy,
|
||||
),
|
||||
),
|
||||
if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
key: const ValueKey('_sliver_timeline'),
|
||||
topSliverWidget: widget.topSliverWidget,
|
||||
topSliverWidgetHeight: widget.topSliverWidgetHeight,
|
||||
appBar: widget.appBar,
|
||||
bottomSheet: widget.bottomSheet,
|
||||
withScrubber: widget.withScrubber,
|
||||
snapToMonth: widget.snapToMonth,
|
||||
initialScrollOffset: widget.initialScrollOffset,
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWithValue(
|
||||
TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
key: const ValueKey('_sliver_timeline'),
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
snapToMonth: snapToMonth,
|
||||
initialScrollOffset: initialScrollOffset,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -202,6 +141,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
int _perRow = 4;
|
||||
double _scaleFactor = 3.0;
|
||||
double _baseScaleFactor = 3.0;
|
||||
int? _restoreAssetIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -242,16 +182,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
|
||||
void _restoreAssetPosition(_) {
|
||||
final restorationState = _TimelineRestorationProvider.of(context);
|
||||
if (!restorationState.shouldRestoreAssetPosition || restorationState.restoreAssetIndex == null) return;
|
||||
if (_restoreAssetIndex == null) return;
|
||||
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final targetSegment = segments.lastWhereOrNull(
|
||||
(segment) => segment.firstAssetIndex <= restorationState.restoreAssetIndex!,
|
||||
);
|
||||
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!);
|
||||
if (targetSegment != null) {
|
||||
final assetIndexInSegment = restorationState.restoreAssetIndex! - targetSegment.firstAssetIndex;
|
||||
final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex;
|
||||
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
|
||||
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
|
||||
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
|
||||
@@ -263,7 +200,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
});
|
||||
}
|
||||
});
|
||||
restorationState.clearRestoreAssetIndex();
|
||||
_restoreAssetIndex = null;
|
||||
}
|
||||
|
||||
int? _getCurrentAssetIndex(List<Segment> segments) {
|
||||
@@ -474,7 +411,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
onNotification: (notification) {
|
||||
final currentIndex = _getCurrentAssetIndex(segments);
|
||||
if (currentIndex != null && mounted) {
|
||||
_TimelineRestorationProvider.of(context).setRestoreAssetIndex(currentIndex);
|
||||
_restoreAssetIndex = currentIndex;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -493,14 +430,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final restorationState = _TimelineRestorationProvider.of(context);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
restorationState.setRestoreAssetIndex(targetAssetIndex);
|
||||
restorationState.setShouldRestoreAssetPosition(true);
|
||||
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@@ -31,18 +31,6 @@ class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
|
||||
}
|
||||
}
|
||||
|
||||
class ScopedAssetNotifier extends CurrentAssetNotifier {
|
||||
final BaseAsset _asset;
|
||||
|
||||
ScopedAssetNotifier(this._asset);
|
||||
|
||||
@override
|
||||
BaseAsset? build() {
|
||||
setAsset(_asset);
|
||||
return _asset;
|
||||
}
|
||||
}
|
||||
|
||||
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
|
||||
final currentAsset = ref.watch(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
|
||||
|
||||
class TagNotifier extends AsyncNotifier<Set<Tag>> {
|
||||
@override
|
||||
Future<Set<Tag>> build() async {
|
||||
final repo = ref.read(tagsApiRepositoryProvider);
|
||||
final allTags = await repo.getAllTags();
|
||||
if (allTags == null) {
|
||||
return {};
|
||||
}
|
||||
return allTags.map((t) => Tag.fromDto(t)).toSet();
|
||||
}
|
||||
}
|
||||
|
||||
final tagProvider = AsyncNotifierProvider<TagNotifier, Set<Tag>>(TagNotifier.new);
|
||||
@@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
|
||||
import 'package:immich_mobile/pages/search/search.page.dart';
|
||||
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
|
||||
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||
@@ -88,6 +88,7 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
|
||||
@@ -337,6 +338,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
|
||||
@@ -753,17 +753,10 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
|
||||
DriftActivitiesRoute({
|
||||
Key? key,
|
||||
required RemoteAlbum album,
|
||||
String? assetId,
|
||||
String? assetName,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftActivitiesRoute.name,
|
||||
args: DriftActivitiesRouteArgs(
|
||||
key: key,
|
||||
album: album,
|
||||
assetId: assetId,
|
||||
assetName: assetName,
|
||||
),
|
||||
args: DriftActivitiesRouteArgs(key: key, album: album),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
@@ -773,35 +766,21 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftActivitiesRouteArgs>();
|
||||
return DriftActivitiesPage(
|
||||
key: args.key,
|
||||
album: args.album,
|
||||
assetId: args.assetId,
|
||||
assetName: args.assetName,
|
||||
);
|
||||
return DriftActivitiesPage(key: args.key, album: args.album);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftActivitiesRouteArgs {
|
||||
const DriftActivitiesRouteArgs({
|
||||
this.key,
|
||||
required this.album,
|
||||
this.assetId,
|
||||
this.assetName,
|
||||
});
|
||||
const DriftActivitiesRouteArgs({this.key, required this.album});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final RemoteAlbum album;
|
||||
|
||||
final String? assetId;
|
||||
|
||||
final String? assetName;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}';
|
||||
return 'DriftActivitiesRouteArgs{key: $key, album: $album}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1873,6 +1852,22 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [ImmichUIShowcasePage]
|
||||
class ImmichUIShowcaseRoute extends PageRouteInfo<void> {
|
||||
const ImmichUIShowcaseRoute({List<PageRouteInfo>? children})
|
||||
: super(ImmichUIShowcaseRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'ImmichUIShowcaseRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const ImmichUIShowcasePage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LibraryPage]
|
||||
class LibraryRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -35,7 +35,6 @@ class ApiService implements Authentication {
|
||||
late ViewsApi viewApi;
|
||||
late MemoriesApi memoriesApi;
|
||||
late SessionsApi sessionsApi;
|
||||
late TagsApi tagsApi;
|
||||
|
||||
ApiService() {
|
||||
// The below line ensures that the api clients are initialized when the service is instantiated
|
||||
@@ -75,7 +74,6 @@ class ApiService implements Authentication {
|
||||
viewApi = ViewsApi(_apiClient);
|
||||
memoriesApi = MemoriesApi(_apiClient);
|
||||
sessionsApi = SessionsApi(_apiClient);
|
||||
tagsApi = TagsApi(_apiClient);
|
||||
}
|
||||
|
||||
Future<void> _setUserAgentHeader() async {
|
||||
|
||||
@@ -225,7 +225,7 @@ enum ActionButtonType {
|
||||
iconData: Icons.info_outline,
|
||||
iconColor: context.originalTheme?.iconTheme.color,
|
||||
menuItem: true,
|
||||
onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()),
|
||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||
),
|
||||
ActionButtonType.viewInTimeline => BaseActionButton(
|
||||
label: 'view_in_timeline'.tr(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user