mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' of https://github.com/immich-app/immich into feat/crawl-wrapper
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.0
|
24.13.1
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -79,12 +79,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -96,14 +96,14 @@ jobs:
|
|||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||||
|
|
||||||
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
|
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Restore Gradle Cache
|
- name: Restore Gradle Cache
|
||||||
id: cache-gradle-restore
|
id: cache-gradle-restore
|
||||||
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -160,7 +160,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Save Gradle Cache
|
- name: Save Gradle Cache
|
||||||
id: cache-gradle-save
|
id: cache-gradle-save
|
||||||
uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -185,7 +185,7 @@ jobs:
|
|||||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ jobs:
|
|||||||
actions: write
|
actions: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Check OpenAPI
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'open-api/**'
|
||||||
|
- '.github/workflows/check-openapi.yml'
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
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
|
||||||
@@ -31,12 +31,12 @@ jobs:
|
|||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
@@ -71,13 +71,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
file: cli/Dockerfile
|
file: cli/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
needs: [get_body, should_run]
|
needs: [get_body, should_run]
|
||||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
|
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Close LLM-generated PRs
|
name: Close LLM-generated PRs
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request_target:
|
||||||
types: [labeled]
|
types: [labeled]
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
gh api graphql \
|
gh api graphql \
|
||||||
-f prId="$NODE_ID" \
|
-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, 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](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 query='
|
-f query='
|
||||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||||
addComment(input: {
|
addComment(input: {
|
||||||
|
|||||||
@@ -44,20 +44,20 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ 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
|
# 📚 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
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
suffix: ['']
|
suffix: ['']
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
suffixes: '-rocm'
|
suffixes: '-rocm'
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
name: Build and Push Server
|
name: Build and Push Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -54,13 +54,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './docs/.nvmrc'
|
node-version-file: './docs/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
artifact: ${{ steps.get-artifact.outputs.result }}
|
artifact: ${{ steps.get-artifact.outputs.result }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
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 }}
|
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||||
|
|
||||||
- name: Load parameters
|
- name: Load parameters
|
||||||
id: parameters
|
id: parameters
|
||||||
@@ -192,16 +192,13 @@ jobs:
|
|||||||
' >> $GITHUB_OUTPUT
|
' >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Publish to Cloudflare Pages
|
- name: Publish to Cloudflare Pages
|
||||||
# TODO: Action is deprecated
|
working-directory: docs
|
||||||
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0
|
env:
|
||||||
with:
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
PROJECT_NAME: ${{ steps.docs-output.outputs.projectName }}
|
||||||
projectName: ${{ steps.docs-output.outputs.projectName }}
|
BRANCH_NAME: ${{ steps.parameters.outputs.name }}
|
||||||
workingDirectory: 'docs'
|
run: mise run //docs:deploy
|
||||||
directory: 'build'
|
|
||||||
branch: ${{ steps.parameters.outputs.name }}
|
|
||||||
wranglerVersion: '3'
|
|
||||||
|
|
||||||
- name: Deploy Docs Release Domain
|
- name: Deploy Docs Release Domain
|
||||||
if: ${{ steps.parameters.outputs.event == 'release' }}
|
if: ${{ steps.parameters.outputs.event == 'release' }}
|
||||||
|
|||||||
@@ -17,19 +17,19 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||||
|
|
||||||
- name: Destroy Docs Subdomain
|
- name: Destroy Docs Subdomain
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: 'Checkout'
|
- name: 'Checkout'
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
@@ -32,14 +32,14 @@ jobs:
|
|||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Fix formatting
|
- name: Fix formatting
|
||||||
run: pnpm --recursive install && pnpm run --recursive --parallel fix:format
|
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
|
||||||
|
|
||||||
- name: Commit and push
|
- name: Commit and push
|
||||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
@@ -56,20 +56,20 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -130,7 +130,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
@@ -23,20 +23,20 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create PR
|
- name: Create PR
|
||||||
id: create-pr
|
id: create-pr
|
||||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -88,6 +88,7 @@ jobs:
|
|||||||
draft: true
|
draft: true
|
||||||
files: |
|
files: |
|
||||||
docker/docker-compose.yml
|
docker/docker-compose.yml
|
||||||
|
docker/docker-compose.rootless.yml
|
||||||
docker/example.env
|
docker/example.env
|
||||||
docker/hwaccel.ml.yml
|
docker/hwaccel.ml.yml
|
||||||
docker/hwaccel.transcoding.yml
|
docker/hwaccel.transcoding.yml
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ jobs:
|
|||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -49,13 +49,13 @@ jobs:
|
|||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
+71
-57
@@ -17,14 +17,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -63,13 +63,13 @@ jobs:
|
|||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -108,20 +108,20 @@ jobs:
|
|||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -155,20 +155,20 @@ jobs:
|
|||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -197,20 +197,20 @@ jobs:
|
|||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -241,20 +241,20 @@ jobs:
|
|||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -279,20 +279,20 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -327,20 +327,20 @@ jobs:
|
|||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -373,13 +373,13 @@ jobs:
|
|||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -387,7 +387,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -412,13 +412,13 @@ jobs:
|
|||||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -426,7 +426,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -446,12 +446,29 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Docker build
|
- name: Start Docker Compose
|
||||||
run: docker compose build
|
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Run e2e tests (api & cli)
|
- name: Run e2e tests (api & cli)
|
||||||
|
env:
|
||||||
|
VITEST_DISABLE_DOCKER_SETUP: true
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
if: ${{ !cancelled() }}
|
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:
|
e2e-tests-web:
|
||||||
name: End-to-End Tests (Web)
|
name: End-to-End Tests (Web)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -467,13 +484,13 @@ jobs:
|
|||||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -481,7 +498,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -501,9 +518,8 @@ jobs:
|
|||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Run e2e tests (web)
|
- name: Run e2e tests (web)
|
||||||
env:
|
env:
|
||||||
CI: true
|
|
||||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||||
run: npx playwright test --project=web
|
run: pnpm test:web
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive e2e test (web) results
|
- name: Archive e2e test (web) results
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
@@ -513,9 +529,8 @@ jobs:
|
|||||||
path: e2e/playwright-report/
|
path: e2e/playwright-report/
|
||||||
- name: Run ui tests (web)
|
- name: Run ui tests (web)
|
||||||
env:
|
env:
|
||||||
CI: true
|
|
||||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||||
run: npx playwright test --project=ui
|
run: pnpm test:web:ui
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive ui test (web) results
|
- name: Archive ui test (web) results
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
@@ -525,9 +540,8 @@ jobs:
|
|||||||
path: e2e/playwright-report/
|
path: e2e/playwright-report/
|
||||||
- name: Run maintenance tests
|
- name: Run maintenance tests
|
||||||
env:
|
env:
|
||||||
CI: true
|
|
||||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||||
run: npx playwright test --project=maintenance
|
run: pnpm test:web:maintenance
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive maintenance tests (web) results
|
- name: Archive maintenance tests (web) results
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
@@ -543,7 +557,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: docker-compose-logs-${{ matrix.runner }}
|
name: e2e-web-docker-logs-${{ matrix.runner }}
|
||||||
path: e2e/docker-compose-logs.txt
|
path: e2e/docker-compose-logs.txt
|
||||||
success-check-e2e:
|
success-check-e2e:
|
||||||
name: End-to-End Tests Success
|
name: End-to-End Tests Success
|
||||||
@@ -564,12 +578,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -596,17 +610,17 @@ jobs:
|
|||||||
working-directory: ./machine-learning
|
working-directory: ./machine-learning
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -636,20 +650,20 @@ jobs:
|
|||||||
working-directory: ./.github
|
working-directory: ./.github
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './.github/.nvmrc'
|
node-version-file: './.github/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -666,12 +680,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -687,20 +701,20 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -749,20 +763,20 @@ jobs:
|
|||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.0
|
24.13.1
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.10.11",
|
"@types/node": "^24.10.13",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
@@ -69,6 +69,6 @@
|
|||||||
"micromatch": "^4.0.8"
|
"micromatch": "^4.0.8"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.0"
|
"node": "24.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,33 +14,65 @@
|
|||||||
name: immich-dev
|
name: immich-dev
|
||||||
|
|
||||||
services:
|
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:
|
immich-server:
|
||||||
|
extends:
|
||||||
|
service: immich-app-base
|
||||||
|
profiles: !reset []
|
||||||
container_name: immich_server
|
container_name: immich_server
|
||||||
command: ['immich-dev']
|
command: ['immich-dev']
|
||||||
image: immich-server-dev:latest
|
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:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile.dev
|
dockerfile: server/Dockerfile.dev
|
||||||
target: dev
|
target: dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/usr/src/app
|
|
||||||
- ${UPLOAD_LOCATION}/photos:/data
|
- ${UPLOAD_LOCATION}/photos:/data
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
- pnpm_store_server:/buildcache/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
|
- ../plugins:/build/corePlugin
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -63,6 +95,8 @@ services:
|
|||||||
- 9231:9231
|
- 9231:9231
|
||||||
- 2283:2283
|
- 2283:2283
|
||||||
depends_on:
|
depends_on:
|
||||||
|
immich-init:
|
||||||
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
database:
|
database:
|
||||||
@@ -71,6 +105,9 @@ services:
|
|||||||
disable: false
|
disable: false
|
||||||
|
|
||||||
immich-web:
|
immich-web:
|
||||||
|
extends:
|
||||||
|
service: immich-app-base
|
||||||
|
profiles: !reset []
|
||||||
container_name: immich_web
|
container_name: immich_web
|
||||||
image: immich-web-dev:latest
|
image: immich-web-dev:latest
|
||||||
build:
|
build:
|
||||||
@@ -84,20 +121,11 @@ services:
|
|||||||
- 3000:3000
|
- 3000:3000
|
||||||
- 24678:24678
|
- 24678:24678
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/usr/src/app
|
- pnpm_store_web:/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
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
|
immich-init:
|
||||||
|
condition: service_healthy
|
||||||
immich-server:
|
immich-server:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
@@ -116,7 +144,7 @@ services:
|
|||||||
- 3003:3003
|
- 3003:3003
|
||||||
volumes:
|
volumes:
|
||||||
- ../machine-learning/immich_ml:/usr/src/immich_ml
|
- ../machine-learning/immich_ml:/usr/src/immich_ml
|
||||||
- model-cache:/cache
|
- model_cache:/cache
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -156,7 +184,7 @@ services:
|
|||||||
# image: prom/prometheus
|
# image: prom/prometheus
|
||||||
# volumes:
|
# volumes:
|
||||||
# - ./prometheus.yml:/etc/prometheus/prometheus.yml
|
# - ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
# - prometheus-data:/prometheus
|
# - prometheus_data:/prometheus
|
||||||
|
|
||||||
# first login uses admin/admin
|
# first login uses admin/admin
|
||||||
# add data source for http://immich-prometheus:9090 to get started
|
# add data source for http://immich-prometheus:9090 to get started
|
||||||
@@ -167,20 +195,22 @@ services:
|
|||||||
# - 3000:3000
|
# - 3000:3000
|
||||||
# image: grafana/grafana:10.3.3-ubuntu
|
# image: grafana/grafana:10.3.3-ubuntu
|
||||||
# volumes:
|
# volumes:
|
||||||
# - grafana-data:/var/lib/grafana
|
# - grafana_data:/var/lib/grafana
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model_cache:
|
||||||
prometheus-data:
|
prometheus_data:
|
||||||
grafana-data:
|
grafana_data:
|
||||||
pnpm-store:
|
pnpm_cache:
|
||||||
server-node_modules:
|
pnpm_store_server:
|
||||||
web-node_modules:
|
pnpm_store_web:
|
||||||
github-node_modules:
|
server_node_modules:
|
||||||
cli-node_modules:
|
web_node_modules:
|
||||||
docs-node_modules:
|
github_node_modules:
|
||||||
e2e-node_modules:
|
cli_node_modules:
|
||||||
sdk-node_modules:
|
docs_node_modules:
|
||||||
app-node_modules:
|
e2e_node_modules:
|
||||||
|
sdk_node_modules:
|
||||||
|
app_node_modules:
|
||||||
sveltekit:
|
sveltekit:
|
||||||
coverage:
|
coverage:
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.0
|
24.13.1
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ If you encounter issues:
|
|||||||
1. Check container logs: View → Output → Select "Dev Containers"
|
1. Check container logs: View → Output → Select "Dev Containers"
|
||||||
2. Rebuild without cache: "Dev Containers: Rebuild Container Without Cache"
|
2. Rebuild without cache: "Dev Containers: Rebuild Container Without Cache"
|
||||||
3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/)
|
3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/)
|
||||||
4. Ask in [Discord](https://discord.immich.app) `#help-desk-support` channel
|
4. Ask in [Discord](https://discord.immich.app) `#contributing` channel
|
||||||
|
|
||||||
## Mobile Development
|
## Mobile Development
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ A config file can be provided as an alternative to the UI configuration.
|
|||||||
|
|
||||||
### Step 1 - Create a new config file
|
### 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 that can be accessed by Immich.
|
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.
|
||||||
The default configuration looks like this:
|
The default configuration looks like this:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -251,6 +252,15 @@ 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.
|
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.
|
For more information, refer to the [Environment Variables](/install/environment-variables.md) section.
|
||||||
|
|
||||||
:::tip
|
:::info Docker Compose
|
||||||
YAML-formatted config files are also supported.
|
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}
|
||||||
|
```
|
||||||
|
|
||||||
|
::
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ sidebar_position: 85
|
|||||||
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
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/).
|
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.
|
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,3 +23,9 @@ run = "prettier --check ."
|
|||||||
[tasks."format-fix"]
|
[tasks."format-fix"]
|
||||||
env._.path = "./node_modules/.bin"
|
env._.path = "./node_modules/.bin"
|
||||||
run = "prettier --write ."
|
run = "prettier --write ."
|
||||||
|
|
||||||
|
[tasks.deploy]
|
||||||
|
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||||
|
|
||||||
|
[tools]
|
||||||
|
wrangler = "4.66.0"
|
||||||
|
|||||||
+1
-1
@@ -58,6 +58,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.0"
|
"node": "24.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.0
|
24.13.1
|
||||||
|
|||||||
+60
-67
@@ -1,86 +1,77 @@
|
|||||||
name: immich-e2e
|
name: immich-e2e
|
||||||
|
|
||||||
services:
|
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:
|
immich-server:
|
||||||
|
extends:
|
||||||
|
file: ../docker/docker-compose.dev.yml
|
||||||
|
service: immich-server
|
||||||
container_name: immich-e2e-server
|
container_name: immich-e2e-server
|
||||||
command: ['immich-dev']
|
ports: !reset []
|
||||||
image: immich-server-dev:latest
|
env_file: !reset []
|
||||||
build:
|
|
||||||
context: ../
|
|
||||||
dockerfile: server/Dockerfile.dev
|
|
||||||
target: dev
|
|
||||||
environment:
|
environment:
|
||||||
- DB_HOSTNAME=database
|
DB_HOSTNAME: database
|
||||||
- DB_USERNAME=postgres
|
DB_USERNAME: postgres
|
||||||
- DB_PASSWORD=postgres
|
DB_PASSWORD: postgres
|
||||||
- DB_DATABASE_NAME=immich
|
DB_DATABASE_NAME: immich
|
||||||
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
IMMICH_MACHINE_LEARNING_ENABLED: 'false'
|
||||||
- IMMICH_TELEMETRY_INCLUDE=all
|
IMMICH_TELEMETRY_INCLUDE: all
|
||||||
- IMMICH_ENV=testing
|
IMMICH_ENV: testing
|
||||||
- IMMICH_PORT=2285
|
IMMICH_PORT: '2285'
|
||||||
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true'
|
||||||
volumes:
|
volumes:
|
||||||
- ./test-assets:/test-assets
|
- ./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:
|
depends_on:
|
||||||
|
immich-init:
|
||||||
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
database:
|
database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
immich-web:
|
immich-web:
|
||||||
|
extends:
|
||||||
|
file: ../docker/docker-compose.dev.yml
|
||||||
|
service: immich-web
|
||||||
container_name: immich-e2e-web
|
container_name: immich-e2e-web
|
||||||
image: immich-web-dev:latest
|
ports: !override
|
||||||
build:
|
|
||||||
context: ../
|
|
||||||
dockerfile: server/Dockerfile.dev
|
|
||||||
target: dev
|
|
||||||
command: ['immich-web']
|
|
||||||
ports:
|
|
||||||
- 2285:3000
|
- 2285:3000
|
||||||
environment:
|
environment:
|
||||||
- IMMICH_SERVER_URL=http://immich-server:2285/
|
IMMICH_SERVER_URL: http://immich-server:2285/
|
||||||
volumes:
|
depends_on:
|
||||||
- ..:/usr/src/app
|
immich-init:
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
condition: service_healthy
|
||||||
- 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
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
|
extends:
|
||||||
|
file: ../docker/docker-compose.dev.yml
|
||||||
|
service: redis
|
||||||
|
container_name: immich-e2e-redis
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
extends:
|
||||||
|
file: ../docker/docker-compose.dev.yml
|
||||||
|
service: database
|
||||||
|
container_name: immich-e2e-postgres
|
||||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
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:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_DB: immich
|
POSTGRES_DB: immich
|
||||||
ports:
|
|
||||||
- 5435:5432
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
||||||
interval: 1s
|
interval: 1s
|
||||||
@@ -89,17 +80,19 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model_cache:
|
||||||
prometheus-data:
|
prometheus_data:
|
||||||
grafana-data:
|
grafana_data:
|
||||||
pnpm-store:
|
pnpm_cache:
|
||||||
server-node_modules:
|
pnpm_store_server:
|
||||||
web-node_modules:
|
pnpm_store_web:
|
||||||
github-node_modules:
|
server_node_modules:
|
||||||
cli-node_modules:
|
web_node_modules:
|
||||||
docs-node_modules:
|
github_node_modules:
|
||||||
e2e-node_modules:
|
cli_node_modules:
|
||||||
sdk-node_modules:
|
docs_node_modules:
|
||||||
app-node_modules:
|
e2e_node_modules:
|
||||||
|
sdk_node_modules:
|
||||||
|
app_node_modules:
|
||||||
sveltekit:
|
sveltekit:
|
||||||
coverage:
|
coverage:
|
||||||
|
|||||||
+16
-11
@@ -2,6 +2,7 @@ name: immich-e2e
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
e2e-auth-server:
|
e2e-auth-server:
|
||||||
|
container_name: immich-e2e-auth-server
|
||||||
build:
|
build:
|
||||||
context: ../e2e-auth-server
|
context: ../e2e-auth-server
|
||||||
ports:
|
ports:
|
||||||
@@ -22,15 +23,15 @@ services:
|
|||||||
- BUILD_SOURCE_REF=e2e
|
- BUILD_SOURCE_REF=e2e
|
||||||
- BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee
|
- BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee
|
||||||
environment:
|
environment:
|
||||||
- DB_HOSTNAME=database
|
DB_HOSTNAME: database
|
||||||
- DB_USERNAME=postgres
|
DB_USERNAME: postgres
|
||||||
- DB_PASSWORD=postgres
|
DB_PASSWORD: postgres
|
||||||
- DB_DATABASE_NAME=immich
|
DB_DATABASE_NAME: immich
|
||||||
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
IMMICH_MACHINE_LEARNING_ENABLED: 'false'
|
||||||
- IMMICH_TELEMETRY_INCLUDE=all
|
IMMICH_TELEMETRY_INCLUDE: all
|
||||||
- IMMICH_ENV=testing
|
IMMICH_ENV: testing
|
||||||
- IMMICH_PORT=2285
|
IMMICH_PORT: '2285'
|
||||||
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true'
|
||||||
volumes:
|
volumes:
|
||||||
- ./test-assets:/test-assets
|
- ./test-assets:/test-assets
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -42,10 +43,14 @@ services:
|
|||||||
- 2285:2285
|
- 2285:2285
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
|
container_name: immich-e2e-redis
|
||||||
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
|
healthcheck:
|
||||||
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
container_name: immich-e2e-postgres
|
||||||
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|||||||
+9
-4
@@ -7,8 +7,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:web": "npx playwright test",
|
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
|
||||||
"start:web": "npx playwright test --ui",
|
"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",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --write .",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||||
@@ -27,7 +32,7 @@
|
|||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.10.11",
|
"@types/node": "^24.10.13",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
@@ -52,6 +57,6 @@
|
|||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.0"
|
"node": "24.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import dotenv from 'dotenv';
|
|||||||
import { cpus } from 'node:os';
|
import { cpus } from 'node:os';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
dotenv.config({ quiet: true, path: resolve(import.meta.dirname, '.env') });
|
||||||
|
|
||||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_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',
|
name: 'maintenance',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
testDir: './src/specs/maintenance',
|
testDir: './src/specs/maintenance/web',
|
||||||
workers: 1,
|
workers: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+10
-5
@@ -1,15 +1,20 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
// skip `docker compose up` if `make e2e` was already run
|
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[] = [];
|
const globalSetup: string[] = [];
|
||||||
try {
|
if (!skipDockerSetup) {
|
||||||
await fetch('http://127.0.0.1:2285/api/server/ping');
|
try {
|
||||||
} catch {
|
await fetch('http://127.0.0.1:2285/api/server/ping');
|
||||||
globalSetup.push('src/docker-compose.ts');
|
} catch {
|
||||||
|
globalSetup.push('src/docker-compose.ts');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
retry: process.env.CI ? 4 : 0,
|
||||||
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
||||||
globalSetup,
|
globalSetup,
|
||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -14,15 +14,15 @@ config_roots = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
node = "24.13.0"
|
node = "24.13.1"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.28.2"
|
pnpm = "10.29.3"
|
||||||
terragrunt = "0.98.0"
|
terragrunt = "0.98.0"
|
||||||
opentofu = "1.11.4"
|
opentofu = "1.11.4"
|
||||||
java = "21.0.2"
|
java = "21.0.2"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
[tools."github:CQLabs/homebrew-dcm"]
|
||||||
version = "1.30.0"
|
version = "1.35.1"
|
||||||
bin = "dcm"
|
bin = "dcm"
|
||||||
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
version: '>=1.29.0 <=1.30.0'
|
version: '>=1.29.0 <=1.36.0'
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ class ScrollToDateEvent extends Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Asset Viewer Events
|
// Asset Viewer Events
|
||||||
class ViewerOpenBottomSheetEvent extends Event {
|
class ViewerShowDetailsEvent extends Event {
|
||||||
final bool activitiesMode;
|
const ViewerShowDetailsEvent();
|
||||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewerReloadAssetEvent extends Event {
|
class ViewerReloadAssetEvent extends Event {
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ class TimelineService {
|
|||||||
return _buffer.slice(start, start + count);
|
return _buffer.slice(start, start + count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-cache assets around the given index for asset viewer
|
// Preload assets around the given index for asset viewer
|
||||||
Future<void> preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||||
|
|
||||||
BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length));
|
BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length));
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,125 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
|||||||
damping: 80,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class ServerFeatures {
|
|||||||
final bool oauthEnabled;
|
final bool oauthEnabled;
|
||||||
final bool passwordLogin;
|
final bool passwordLogin;
|
||||||
final bool ocr;
|
final bool ocr;
|
||||||
|
final bool smartSearch;
|
||||||
|
|
||||||
const ServerFeatures({
|
const ServerFeatures({
|
||||||
required this.trash,
|
required this.trash,
|
||||||
@@ -13,21 +14,30 @@ class ServerFeatures {
|
|||||||
required this.oauthEnabled,
|
required this.oauthEnabled,
|
||||||
required this.passwordLogin,
|
required this.passwordLogin,
|
||||||
this.ocr = false,
|
this.ocr = false,
|
||||||
|
this.smartSearch = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin, bool? ocr}) {
|
ServerFeatures copyWith({
|
||||||
|
bool? trash,
|
||||||
|
bool? map,
|
||||||
|
bool? oauthEnabled,
|
||||||
|
bool? passwordLogin,
|
||||||
|
bool? ocr,
|
||||||
|
bool? smartSearch,
|
||||||
|
}) {
|
||||||
return ServerFeatures(
|
return ServerFeatures(
|
||||||
trash: trash ?? this.trash,
|
trash: trash ?? this.trash,
|
||||||
map: map ?? this.map,
|
map: map ?? this.map,
|
||||||
oauthEnabled: oauthEnabled ?? this.oauthEnabled,
|
oauthEnabled: oauthEnabled ?? this.oauthEnabled,
|
||||||
passwordLogin: passwordLogin ?? this.passwordLogin,
|
passwordLogin: passwordLogin ?? this.passwordLogin,
|
||||||
ocr: ocr ?? this.ocr,
|
ocr: ocr ?? this.ocr,
|
||||||
|
smartSearch: smartSearch ?? this.smartSearch,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr)';
|
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)';
|
||||||
}
|
}
|
||||||
|
|
||||||
ServerFeatures.fromDto(ServerFeaturesDto dto)
|
ServerFeatures.fromDto(ServerFeaturesDto dto)
|
||||||
@@ -35,7 +45,8 @@ class ServerFeatures {
|
|||||||
map = dto.map,
|
map = dto.map,
|
||||||
oauthEnabled = dto.oauth,
|
oauthEnabled = dto.oauth,
|
||||||
passwordLogin = dto.passwordLogin,
|
passwordLogin = dto.passwordLogin,
|
||||||
ocr = dto.ocr;
|
ocr = dto.ocr,
|
||||||
|
smartSearch = dto.smartSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant ServerFeatures other) {
|
bool operator ==(covariant ServerFeatures other) {
|
||||||
@@ -45,11 +56,17 @@ class ServerFeatures {
|
|||||||
other.map == map &&
|
other.map == map &&
|
||||||
other.oauthEnabled == oauthEnabled &&
|
other.oauthEnabled == oauthEnabled &&
|
||||||
other.passwordLogin == passwordLogin &&
|
other.passwordLogin == passwordLogin &&
|
||||||
other.ocr == ocr;
|
other.ocr == ocr &&
|
||||||
|
other.smartSearch == smartSearch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode {
|
||||||
return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode;
|
return trash.hashCode ^
|
||||||
|
map.hashCode ^
|
||||||
|
oauthEnabled.hashCode ^
|
||||||
|
passwordLogin.hashCode ^
|
||||||
|
ocr.hashCode ^
|
||||||
|
smartSearch.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final user = sharedUsers.value[index];
|
final user = sharedUsers.value[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: UserCircleAvatar(user: user, radius: 22),
|
leading: UserCircleAvatar(user: user),
|
||||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||||
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
|
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget {
|
|||||||
itemBuilder: ((context, index) {
|
itemBuilder: ((context, index) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
child: UserCircleAvatar(user: sharedUsers.value[index], radius: 18, size: 36),
|
child: UserCircleAvatar(user: sharedUsers.value[index], size: 36),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
itemCount: sharedUsers.value.length,
|
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
|
// finds the nearest asset marker from the tap point and store it as the selectedMarker
|
||||||
Future<void> onMarkerClicked(Point<double> point, LatLng coords) async {
|
Future<void> onMarkerClicked(Point<double> point, LatLng _) async {
|
||||||
// Guard map not created
|
// Guard map not created
|
||||||
if (mapController.value == null) {
|
if (mapController.value == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
|||||||
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
|
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onMapClick(Point<num> point, LatLng centre) async {
|
Future<void> onMapClick(Point<num> _, LatLng centre) async {
|
||||||
selectedLatLng.value = centre;
|
selectedLatLng.value = centre;
|
||||||
await controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
|
await controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
|
||||||
if (marker.value != null) {
|
if (marker.value != null) {
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
|||||||
@RoutePage()
|
@RoutePage()
|
||||||
class DriftActivitiesPage extends HookConsumerWidget {
|
class DriftActivitiesPage extends HookConsumerWidget {
|
||||||
final RemoteAlbum album;
|
final RemoteAlbum album;
|
||||||
|
final String? assetId;
|
||||||
|
final String? assetName;
|
||||||
|
|
||||||
const DriftActivitiesPage({super.key, required this.album});
|
const DriftActivitiesPage({super.key, required this.album, this.assetId, this.assetName});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final activityNotifier = ref.read(albumActivityProvider(album.id).notifier);
|
final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier);
|
||||||
final activities = ref.watch(albumActivityProvider(album.id));
|
final activities = ref.watch(albumActivityProvider(album.id, assetId));
|
||||||
final listViewScrollController = useScrollController();
|
final listViewScrollController = useScrollController();
|
||||||
|
|
||||||
void scrollToBottom() {
|
void scrollToBottom() {
|
||||||
@@ -36,7 +38,13 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
|||||||
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
|
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(album.name),
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(album.name),
|
||||||
|
if (assetName != null) Text(assetName!, style: context.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [const LikeActivityActionButton(iconOnly: true)],
|
actions: [const LikeActivityActionButton(iconOnly: true)],
|
||||||
actionsPadding: const EdgeInsets.only(right: 8),
|
actionsPadding: const EdgeInsets.only(right: 8),
|
||||||
),
|
),
|
||||||
@@ -47,7 +55,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
|||||||
activityWidgets.add(
|
activityWidgets.add(
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
child: CommentBubble(activity: activity),
|
child: CommentBubble(activity: activity, isAssetActivity: assetId != null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add_rounded, size: 28),
|
|
||||||
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
|
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
showUploadButton: false,
|
showUploadButton: false,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: UserCircleAvatar(user: user, radius: 22),
|
leading: UserCircleAvatar(user: user),
|
||||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||||
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
|
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
|
||||||
@@ -169,7 +169,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final user = sharedUsers[index];
|
final user = sharedUsers[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: UserCircleAvatar(user: user, radius: 22),
|
leading: UserCircleAvatar(user: user),
|
||||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||||
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
|
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/presentation/widgets/memory/memory_card.widget.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.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/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
||||||
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.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/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/routing/router.dart';
|
||||||
import 'package:immich_mobile/widgets/common/feature_check.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/search_field.dart';
|
||||||
@@ -39,8 +40,15 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final textSearchType = useState<TextSearchType>(TextSearchType.context);
|
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
|
||||||
final searchHintText = useState<String>('sunrise_on_the_beach'.t(context: context));
|
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 textSearchController = useTextEditingController();
|
final textSearchController = useTextEditingController();
|
||||||
final preFilter = ref.watch(searchPreFilterProvider);
|
final preFilter = ref.watch(searchPreFilterProvider);
|
||||||
final filter = useState<SearchFilter>(
|
final filter = useState<SearchFilter>(
|
||||||
@@ -518,23 +526,26 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
menuChildren: [
|
menuChildren: [
|
||||||
MenuItemButton(
|
FeatureCheck(
|
||||||
child: ListTile(
|
feature: (features) => features.smartSearch,
|
||||||
leading: const Icon(Icons.image_search_rounded),
|
child: MenuItemButton(
|
||||||
title: Text(
|
child: ListTile(
|
||||||
'search_by_context'.t(context: context),
|
leading: const Icon(Icons.image_search_rounded),
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
title: Text(
|
||||||
fontWeight: FontWeight.w500,
|
'search_by_context'.t(context: context),
|
||||||
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
|
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,
|
||||||
),
|
),
|
||||||
selectedColor: context.colorScheme.primary,
|
onPressed: () {
|
||||||
selected: textSearchType.value == TextSearchType.context,
|
textSearchType.value = TextSearchType.context;
|
||||||
|
searchHintText.value = 'sunrise_on_the_beach'.t(context: context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onPressed: () {
|
|
||||||
textSearchType.value = TextSearchType.context;
|
|
||||||
searchHintText.value = 'sunrise_on_the_beach'.t(context: context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.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/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_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/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.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/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.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/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class EditImageActionButton extends ConsumerWidget {
|
class EditImageActionButton extends ConsumerWidget {
|
||||||
|
|||||||
+1
-1
@@ -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/models/activities/activity.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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/activity.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.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/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.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.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField>
|
|||||||
prefixIcon: user != null
|
prefixIcon: user != null
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||||
child: UserCircleAvatar(user: user, size: 30, radius: 15),
|
child: UserCircleAvatar(user: user, size: 30),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
|
|||||||
@@ -1,85 +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/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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
+5
-6
@@ -8,18 +8,18 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_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/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
class SheetLocationDetails extends ConsumerStatefulWidget {
|
class LocationDetails extends ConsumerStatefulWidget {
|
||||||
const SheetLocationDetails({super.key});
|
const LocationDetails({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SheetLocationDetailsState();
|
ConsumerState createState() => _LocationDetailsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||||
MapLibreMapController? _mapController;
|
MapLibreMapController? _mapController;
|
||||||
|
|
||||||
String? _getLocationName(ExifInfo? exifInfo) {
|
String? _getLocationName(ExifInfo? exifInfo) {
|
||||||
@@ -42,7 +42,6 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
|||||||
|
|
||||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||||
final currentExif = current.valueOrNull;
|
final currentExif = current.valueOrNull;
|
||||||
|
|
||||||
if (currentExif != null && currentExif.hasCoordinates) {
|
if (currentExif != null && currentExif.hasCoordinates) {
|
||||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||||
}
|
}
|
||||||
+8
-8
@@ -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/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/presentation/widgets/people/person_edit_name_modal.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_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/image_url_builder.dart';
|
||||||
import 'package:immich_mobile/utils/people.utils.dart';
|
import 'package:immich_mobile/utils/people.utils.dart';
|
||||||
|
|
||||||
class SheetPeopleDetails extends ConsumerStatefulWidget {
|
class PeopleDetails extends ConsumerStatefulWidget {
|
||||||
const SheetPeopleDetails({super.key});
|
const PeopleDetails({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SheetPeopleDetailsState();
|
ConsumerState createState() => _PeopleDetailsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
@@ -65,7 +65,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
children: [
|
children: [
|
||||||
for (final person in people)
|
for (final person in people)
|
||||||
_PeopleAvatar(
|
_Avatar(
|
||||||
person: person,
|
person: person,
|
||||||
assetFileCreatedAt: asset.createdAt,
|
assetFileCreatedAt: asset.createdAt,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -97,14 +97,14 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PeopleAvatar extends StatelessWidget {
|
class _Avatar extends StatelessWidget {
|
||||||
final DriftPerson person;
|
final DriftPerson person;
|
||||||
final DateTime assetFileCreatedAt;
|
final DateTime assetFileCreatedAt;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onNameTap;
|
final VoidCallback? onNameTap;
|
||||||
final double imageSize = 96;
|
final double imageSize = 96;
|
||||||
|
|
||||||
const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
|
const _Avatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
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 _blockGestures = false;
|
||||||
|
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 _dragInProgress = false;
|
||||||
|
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 (_blockGestures) return;
|
||||||
|
|
||||||
|
_dragInProgress = true;
|
||||||
|
|
||||||
|
if (_dragIntent == _DragIntent.none) {
|
||||||
|
_dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) {
|
||||||
|
< -kTouchSlop => _DragIntent.scroll,
|
||||||
|
> kTouchSlop => _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) {
|
||||||
|
_dragInProgress = false;
|
||||||
|
|
||||||
|
if (_blockGestures) {
|
||||||
|
_blockGestures = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final intent = _dragIntent;
|
||||||
|
_dragIntent = _DragIntent.none;
|
||||||
|
_dragStart = null;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
_blockGestures = true;
|
||||||
|
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 && !_dragInProgress) _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 (!_dragInProgress) _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 margin = (viewportHeight - imageHeight) / 2;
|
||||||
|
final overflowBoxHeight = margin + imageHeight - (kMinInteractiveDimension / 2);
|
||||||
|
_snapOffset = (margin + imageHeight) - (viewportHeight / 4);
|
||||||
|
|
||||||
|
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: overflowBoxHeight),
|
||||||
|
GestureDetector(
|
||||||
|
onVerticalDragStart: _beginDrag,
|
||||||
|
onVerticalDragUpdate: _updateDrag,
|
||||||
|
onVerticalDragEnd: _endDrag,
|
||||||
|
onVerticalDragCancel: _onDragCancel,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _showingDetails ? 1.0 : 0.0,
|
||||||
|
duration: Durations.short2,
|
||||||
|
child: AssetDetails(minHeight: _snapOffset + viewportHeight - overflowBoxHeight),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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_stack.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
|
|
||||||
class AssetStackRow extends ConsumerWidget {
|
class AssetStackRow extends ConsumerWidget {
|
||||||
const AssetStackRow({super.key});
|
const AssetStackRow({super.key});
|
||||||
@@ -21,17 +21,11 @@ class AssetStackRow extends ConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||||
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;
|
if (showingDetails) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
return IgnorePointer(
|
}
|
||||||
ignoring: opacity < 255,
|
return _StackList(stack: stackChildren);
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: opacity / 255,
|
|
||||||
duration: Durations.short2,
|
|
||||||
child: _StackList(stack: stackChildren),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,27 +14,19 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/scroll_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/action_buttons/download_status_floating_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.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/asset_stack.provider.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/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.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_controls_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.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/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.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class AssetViewerPage extends StatelessWidget {
|
class AssetViewerPage extends StatelessWidget {
|
||||||
@@ -79,10 +71,6 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||||||
_setAsset(ref, asset);
|
_setAsset(ref, asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
void changeAsset(WidgetRef ref, BaseAsset asset) {
|
|
||||||
_setAsset(ref, asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||||
// Always holds the current asset from the timeline
|
// Always holds the current asset from the timeline
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||||
@@ -94,45 +82,20 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
}
|
}
|
||||||
// Hide controls by default for videos
|
// Hide controls by default for videos
|
||||||
if (asset.isVideo) {
|
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const double _kBottomSheetMinimumExtent = 0.4;
|
|
||||||
const double _kBottomSheetSnapExtent = 0.67;
|
|
||||||
|
|
||||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
|
||||||
late PageController pageController;
|
late PageController pageController;
|
||||||
late DraggableScrollableController bottomSheetController;
|
|
||||||
PersistentBottomSheetController? sheetCloseController;
|
StreamSubscription? _reloadSubscription;
|
||||||
// PhotoViewGallery takes care of disposing it's controllers
|
|
||||||
PhotoViewControllerBase? viewController;
|
|
||||||
StreamSubscription? reloadSubscription;
|
|
||||||
|
|
||||||
late final int heroOffset;
|
late final int heroOffset;
|
||||||
late PhotoViewControllerValue initialPhotoViewState;
|
bool _assetReloadRequested = false;
|
||||||
bool? hasDraggedDown;
|
int _totalAssets = 0;
|
||||||
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;
|
|
||||||
|
|
||||||
|
late final AssetPreloader _preloader;
|
||||||
KeepAliveLink? _stackChildrenKeepAlive;
|
KeepAliveLink? _stackChildrenKeepAlive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -140,94 +103,38 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
|
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
|
||||||
pageController = PageController(initialPage: widget.initialIndex);
|
pageController = PageController(initialPage: widget.initialIndex);
|
||||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
bottomSheetController = DraggableScrollableController();
|
_totalAssets = timelineService.totalAssets;
|
||||||
|
_preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted);
|
||||||
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
||||||
reloadSubscription = EventStream.shared.listen(_onEvent);
|
_reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||||
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(currentAssetNotifier);
|
||||||
if (asset != null) {
|
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||||
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
pageController.dispose();
|
pageController.dispose();
|
||||||
bottomSheetController.dispose();
|
_preloader.dispose();
|
||||||
_cancelTimers();
|
_reloadSubscription?.cancel();
|
||||||
reloadSubscription?.cancel();
|
|
||||||
_prevPreCacheStream?.removeListener(_dummyListener);
|
|
||||||
_nextPreCacheStream?.removeListener(_dummyListener);
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
||||||
_stackChildrenKeepAlive?.close();
|
_stackChildrenKeepAlive?.close();
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
|
void _onAssetInit(Duration timeStamp) {
|
||||||
|
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||||
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();
|
_handleCasting();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetChanged(int index) async {
|
void _onAssetChanged(int index) async {
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
final asset = await timelineService.getAssetAsync(index);
|
final asset = await timelineService.getAssetAsync(index);
|
||||||
if (asset == null) {
|
if (asset == null) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.changeAsset(ref, asset);
|
AssetViewer._setAsset(ref, asset);
|
||||||
_precacheAssets(index);
|
_preloader.preload(index, context.sizeData);
|
||||||
_handleCasting();
|
_handleCasting();
|
||||||
_stackChildrenKeepAlive?.close();
|
_stackChildrenKeepAlive?.close();
|
||||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||||
@@ -238,223 +145,40 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(currentAssetNotifier);
|
||||||
if (asset == null) return;
|
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) {
|
if (asset is RemoteAsset) {
|
||||||
|
context.scaffoldMessenger.hideCurrentSnackBar();
|
||||||
ref.read(castProvider.notifier).loadMedia(asset, false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not reset the state if the bottom sheet is showing
|
context.scaffoldMessenger.clearSnackBars();
|
||||||
if (showingBottomSheet) {
|
ref.read(castProvider.notifier).stop();
|
||||||
_snapBottomSheet();
|
context.scaffoldMessenger.showSnackBar(
|
||||||
return;
|
SnackBar(
|
||||||
}
|
duration: const Duration(seconds: 2),
|
||||||
|
content: Text(
|
||||||
// If the gestures are blocked, do not reset the state
|
"local_asset_cast_failed".tr(),
|
||||||
if (blockGestures) {
|
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||||
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) {
|
void _onEvent(Event event) {
|
||||||
if (event is TimelineReloadEvent) {
|
switch (event) {
|
||||||
_onTimelineReloadEvent();
|
case TimelineReloadEvent():
|
||||||
return;
|
_onTimelineReloadEvent();
|
||||||
}
|
case ViewerReloadAssetEvent():
|
||||||
|
_assetReloadRequested = true;
|
||||||
if (event is ViewerReloadAssetEvent) {
|
default:
|
||||||
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() {
|
void _onTimelineReloadEvent() {
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
totalAssets = timelineService.totalAssets;
|
_totalAssets = timelineService.totalAssets;
|
||||||
|
|
||||||
if (totalAssets == 0) {
|
if (_totalAssets == 0) {
|
||||||
context.maybePop();
|
context.maybePop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -469,229 +193,58 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index >= totalAssets) {
|
if (index >= _totalAssets) {
|
||||||
index = totalAssets - 1;
|
index = _totalAssets - 1;
|
||||||
pageController.jumpToPage(index);
|
pageController.jumpToPage(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetReloadRequested) {
|
if (_assetReloadRequested) {
|
||||||
assetReloadRequested = false;
|
_assetReloadRequested = false;
|
||||||
_onAssetReloadEvent(index);
|
_onAssetReloadEvent(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetReloadEvent(int index) async {
|
void _onAssetReloadEvent(int index) async {
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
final newAsset = await timelineService.getAssetAsync(index);
|
|
||||||
|
|
||||||
if (newAsset == null) {
|
final newAsset = await timelineService.getAssetAsync(index);
|
||||||
return;
|
if (newAsset == null) return;
|
||||||
}
|
|
||||||
|
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(currentAssetNotifier);
|
||||||
// Do not reload / close the bottom sheet if the asset has not changed
|
|
||||||
if (newAsset.heroTag == currentAsset?.heroTag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
// Do not reload if the asset has not changed
|
||||||
_onAssetChanged(pageController.page!.round());
|
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||||
sheetCloseController?.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
|
_onAssetChanged(index);
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 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
|
// Listen for casting changes and send initial asset to the cast provider
|
||||||
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async {
|
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) {
|
||||||
if (!isCasting) return;
|
if (!isCasting) return;
|
||||||
|
|
||||||
final asset = ref.read(currentAssetNotifier);
|
|
||||||
if (asset == null) return;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_handleCasting();
|
_handleCasting();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for control visibility changes and change system UI mode accordingly
|
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
|
||||||
ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async {
|
final (controls, details) = state;
|
||||||
if (showingControls) {
|
final mode = !controls || (CurrentPlatform.isIOS && details)
|
||||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
? SystemUiMode.immersiveSticky
|
||||||
} else {
|
: SystemUiMode.edgeToEdge;
|
||||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
|
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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(
|
return PopScope(
|
||||||
onPopInvokedWithResult: _onPop,
|
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
appBar: const ViewerTopAppBar(),
|
appBar: const ViewerTopAppBar(),
|
||||||
@@ -705,33 +258,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
child: const DownloadStatusFloatingButton(),
|
child: const DownloadStatusFloatingButton(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
PhotoViewGallery.builder(
|
PhotoViewGestureDetectorScope(
|
||||||
gaplessPlayback: true,
|
axis: Axis.horizontal,
|
||||||
loadingBuilder: _placeholderBuilder,
|
child: PageView.builder(
|
||||||
pageController: pageController,
|
controller: pageController,
|
||||||
scrollPhysics: CurrentPlatform.isIOS
|
physics: isZoomed
|
||||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
? const NeverScrollableScrollPhysics()
|
||||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
: CurrentPlatform.isIOS
|
||||||
itemCount: totalAssets,
|
? const FastScrollPhysics()
|
||||||
onPageChanged: _onPageChanged,
|
: const FastClampingScrollPhysics(),
|
||||||
onPageBuild: _onPageBuild,
|
itemCount: _totalAssets,
|
||||||
scaleStateChangedCallback: _onScaleStateChanged,
|
onPageChanged: (index) => _onAssetChanged(index),
|
||||||
builder: _assetBuilder,
|
itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset),
|
||||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
),
|
||||||
enablePanAlways: true,
|
|
||||||
),
|
),
|
||||||
if (!showingBottomSheet)
|
if (!CurrentPlatform.isIOS)
|
||||||
const Positioned(
|
IgnorePointer(
|
||||||
bottom: 0,
|
child: AnimatedContainer(
|
||||||
left: 0,
|
duration: Durations.short2,
|
||||||
right: 0,
|
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||||
child: Column(
|
height: context.padding.top,
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [AssetStackRow(), ViewerBottomBar()],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,31 +3,35 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
class AssetViewerState {
|
class AssetViewerState {
|
||||||
final int backgroundOpacity;
|
final double backgroundOpacity;
|
||||||
final bool showingBottomSheet;
|
final bool showingDetails;
|
||||||
final bool showingControls;
|
final bool showingControls;
|
||||||
|
final bool isZoomed;
|
||||||
final BaseAsset? currentAsset;
|
final BaseAsset? currentAsset;
|
||||||
final int stackIndex;
|
final int stackIndex;
|
||||||
|
|
||||||
const AssetViewerState({
|
const AssetViewerState({
|
||||||
this.backgroundOpacity = 255,
|
this.backgroundOpacity = 1.0,
|
||||||
this.showingBottomSheet = false,
|
this.showingDetails = false,
|
||||||
this.showingControls = true,
|
this.showingControls = true,
|
||||||
|
this.isZoomed = false,
|
||||||
this.currentAsset,
|
this.currentAsset,
|
||||||
this.stackIndex = 0,
|
this.stackIndex = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
AssetViewerState copyWith({
|
AssetViewerState copyWith({
|
||||||
int? backgroundOpacity,
|
double? backgroundOpacity,
|
||||||
bool? showingBottomSheet,
|
bool? showingDetails,
|
||||||
bool? showingControls,
|
bool? showingControls,
|
||||||
|
bool? isZoomed,
|
||||||
BaseAsset? currentAsset,
|
BaseAsset? currentAsset,
|
||||||
int? stackIndex,
|
int? stackIndex,
|
||||||
}) {
|
}) {
|
||||||
return AssetViewerState(
|
return AssetViewerState(
|
||||||
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
||||||
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
|
showingDetails: showingDetails ?? this.showingDetails,
|
||||||
showingControls: showingControls ?? this.showingControls,
|
showingControls: showingControls ?? this.showingControls,
|
||||||
|
isZoomed: isZoomed ?? this.isZoomed,
|
||||||
currentAsset: currentAsset ?? this.currentAsset,
|
currentAsset: currentAsset ?? this.currentAsset,
|
||||||
stackIndex: stackIndex ?? this.stackIndex,
|
stackIndex: stackIndex ?? this.stackIndex,
|
||||||
);
|
);
|
||||||
@@ -35,7 +39,7 @@ class AssetViewerState {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)';
|
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,8 +48,9 @@ class AssetViewerState {
|
|||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is AssetViewerState &&
|
return other is AssetViewerState &&
|
||||||
other.backgroundOpacity == backgroundOpacity &&
|
other.backgroundOpacity == backgroundOpacity &&
|
||||||
other.showingBottomSheet == showingBottomSheet &&
|
other.showingDetails == showingDetails &&
|
||||||
other.showingControls == showingControls &&
|
other.showingControls == showingControls &&
|
||||||
|
other.isZoomed == isZoomed &&
|
||||||
other.currentAsset == currentAsset &&
|
other.currentAsset == currentAsset &&
|
||||||
other.stackIndex == stackIndex;
|
other.stackIndex == stackIndex;
|
||||||
}
|
}
|
||||||
@@ -53,8 +58,9 @@ class AssetViewerState {
|
|||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
backgroundOpacity.hashCode ^
|
backgroundOpacity.hashCode ^
|
||||||
showingBottomSheet.hashCode ^
|
showingDetails.hashCode ^
|
||||||
showingControls.hashCode ^
|
showingControls.hashCode ^
|
||||||
|
isZoomed.hashCode ^
|
||||||
currentAsset.hashCode ^
|
currentAsset.hashCode ^
|
||||||
stackIndex.hashCode;
|
stackIndex.hashCode;
|
||||||
}
|
}
|
||||||
@@ -76,18 +82,18 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setOpacity(int opacity) {
|
void setOpacity(double opacity) {
|
||||||
if (opacity == state.backgroundOpacity) {
|
if (opacity == state.backgroundOpacity) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls);
|
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setBottomSheet(bool showing) {
|
void setShowingDetails(bool showing) {
|
||||||
if (showing == state.showingBottomSheet) {
|
if (showing == state.showingDetails) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls);
|
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||||
if (showing) {
|
if (showing) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
}
|
}
|
||||||
@@ -104,6 +110,13 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
state = state.copyWith(showingControls: !state.showingControls);
|
state = state.copyWith(showingControls: !state.showingControls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setZoomed(bool isZoomed) {
|
||||||
|
if (isZoomed == state.isZoomed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = state.copyWith(isZoomed: isZoomed);
|
||||||
|
}
|
||||||
|
|
||||||
void setStackIndex(int index) {
|
void setStackIndex(int index) {
|
||||||
if (index == state.stackIndex) {
|
if (index == state.stackIndex) {
|
||||||
return;
|
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/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.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/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -29,15 +29,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
|
|
||||||
if (!showControls) {
|
|
||||||
opacity = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
final originalTheme = context.themeData;
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
@@ -56,37 +50,30 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return IgnorePointer(
|
return AnimatedSwitcher(
|
||||||
ignoring: opacity < 255,
|
duration: Durations.short4,
|
||||||
child: AnimatedOpacity(
|
child: showingDetails
|
||||||
opacity: opacity / 255,
|
? const SizedBox.shrink()
|
||||||
duration: Durations.short2,
|
: Theme(
|
||||||
child: AnimatedSwitcher(
|
data: context.themeData.copyWith(
|
||||||
duration: Durations.short4,
|
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
child: isSheetOpen
|
textTheme: context.themeData.textTheme.copyWith(
|
||||||
? const SizedBox.shrink()
|
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
|
||||||
: 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,409 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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/asset_viewer/video_player_value_provider.dart';
|
||||||
import 'package:immich_mobile/providers/cast.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.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
@@ -205,7 +205,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||||
|
|
||||||
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) {
|
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
|
||||||
return;
|
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_controls_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.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/asset_viewer/center_play_button.dart';
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.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) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
||||||
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||||
if (showBottomSheet) {
|
if (showingDetails) {
|
||||||
showControls = false;
|
showControls = false;
|
||||||
}
|
}
|
||||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.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/setting.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
|||||||
+20
-15
@@ -3,16 +3,15 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/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/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/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/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/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/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.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/activity.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.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/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
@@ -35,8 +34,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
|
|
||||||
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
||||||
@@ -44,7 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!showControls) {
|
if (!showControls) {
|
||||||
opacity = 0;
|
opacity = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
final originalTheme = context.themeData;
|
||||||
@@ -55,7 +54,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.chat_outlined),
|
icon: const Icon(Icons.chat_outlined),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
|
context.router.push(
|
||||||
|
DriftActivitiesRoute(
|
||||||
|
album: album,
|
||||||
|
assetId: asset is RemoteAsset ? asset.id : null,
|
||||||
|
assetName: asset.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -70,17 +75,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
|
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: opacity < 255,
|
ignoring: opacity < 1.0,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: opacity / 255,
|
opacity: opacity,
|
||||||
duration: Durations.short2,
|
duration: Durations.short2,
|
||||||
child: AppBar(
|
child: AppBar(
|
||||||
backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125),
|
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
|
||||||
leading: const _AppBarBackButton(),
|
leading: const _AppBarBackButton(),
|
||||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
shape: const Border(),
|
shape: const Border(),
|
||||||
actions: isShowingSheet || isReadonlyModeEnabled
|
actions: showingDetails || isReadonlyModeEnabled
|
||||||
? null
|
? null
|
||||||
: isInLockedView
|
: isInLockedView
|
||||||
? lockedViewActions
|
? lockedViewActions
|
||||||
@@ -99,9 +104,9 @@ class _AppBarBackButton extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||||
final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black;
|
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||||
final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white;
|
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 12.0),
|
padding: const EdgeInsets.only(left: 12.0),
|
||||||
@@ -112,7 +117,7 @@ class _AppBarBackButton extends ConsumerWidget {
|
|||||||
iconSize: 22,
|
iconSize: 22,
|
||||||
iconColor: foregroundColor,
|
iconColor: foregroundColor,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
elevation: isShowingSheet ? 4 : 0,
|
elevation: showingDetails ? 4 : 0,
|
||||||
),
|
),
|
||||||
onPressed: context.maybePop,
|
onPressed: context.maybePop,
|
||||||
child: const Icon(Icons.arrow_back_rounded),
|
child: const Icon(Icons.arrow_back_rounded),
|
||||||
@@ -29,7 +29,38 @@ 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/mesmerizing_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||||
|
|
||||||
class Timeline extends ConsumerWidget {
|
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 {
|
||||||
const Timeline({
|
const Timeline({
|
||||||
super.key,
|
super.key,
|
||||||
this.topSliverWidget,
|
this.topSliverWidget,
|
||||||
@@ -58,36 +89,66 @@ class Timeline extends ConsumerWidget {
|
|||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (_, constraints) => ProviderScope(
|
builder: (_, constraints) {
|
||||||
overrides: [
|
if (_lastWidth != null && _lastWidth != constraints.maxWidth) {
|
||||||
timelineArgsProvider.overrideWithValue(
|
_restorationState.setShouldRestoreAssetPosition(true);
|
||||||
TimelineArgs(
|
}
|
||||||
maxWidth: constraints.maxWidth,
|
_lastWidth = constraints.maxWidth;
|
||||||
maxHeight: constraints.maxHeight,
|
return _TimelineRestorationProvider(
|
||||||
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
|
notifier: _restorationState,
|
||||||
showStorageIndicator: showStorageIndicator,
|
child: ProviderScope(
|
||||||
withStack: withStack,
|
key: ValueKey(_lastWidth),
|
||||||
groupBy: groupBy,
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,7 +202,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
int _perRow = 4;
|
int _perRow = 4;
|
||||||
double _scaleFactor = 3.0;
|
double _scaleFactor = 3.0;
|
||||||
double _baseScaleFactor = 3.0;
|
double _baseScaleFactor = 3.0;
|
||||||
int? _restoreAssetIndex;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -182,13 +242,16 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _restoreAssetPosition(_) {
|
void _restoreAssetPosition(_) {
|
||||||
if (_restoreAssetIndex == null) return;
|
final restorationState = _TimelineRestorationProvider.of(context);
|
||||||
|
if (!restorationState.shouldRestoreAssetPosition || restorationState.restoreAssetIndex == null) return;
|
||||||
|
|
||||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||||
asyncSegments.whenData((segments) {
|
asyncSegments.whenData((segments) {
|
||||||
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!);
|
final targetSegment = segments.lastWhereOrNull(
|
||||||
|
(segment) => segment.firstAssetIndex <= restorationState.restoreAssetIndex!,
|
||||||
|
);
|
||||||
if (targetSegment != null) {
|
if (targetSegment != null) {
|
||||||
final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex;
|
final assetIndexInSegment = restorationState.restoreAssetIndex! - targetSegment.firstAssetIndex;
|
||||||
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
|
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
|
||||||
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
|
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
|
||||||
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
|
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
|
||||||
@@ -200,7 +263,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_restoreAssetIndex = null;
|
restorationState.clearRestoreAssetIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _getCurrentAssetIndex(List<Segment> segments) {
|
int? _getCurrentAssetIndex(List<Segment> segments) {
|
||||||
@@ -411,7 +474,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
onNotification: (notification) {
|
onNotification: (notification) {
|
||||||
final currentIndex = _getCurrentAssetIndex(segments);
|
final currentIndex = _getCurrentAssetIndex(segments);
|
||||||
if (currentIndex != null && mounted) {
|
if (currentIndex != null && mounted) {
|
||||||
_restoreAssetIndex = currentIndex;
|
_TimelineRestorationProvider.of(context).setRestoreAssetIndex(currentIndex);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -430,12 +493,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||||
|
|
||||||
if (newPerRow != _perRow) {
|
if (newPerRow != _perRow) {
|
||||||
|
final restorationState = _TimelineRestorationProvider.of(context);
|
||||||
setState(() {
|
setState(() {
|
||||||
_scaleFactor = newScaleFactor;
|
_scaleFactor = newScaleFactor;
|
||||||
_perRow = newPerRow;
|
_perRow = newPerRow;
|
||||||
_restoreAssetIndex = targetAssetIndex;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
restorationState.setRestoreAssetIndex(targetAssetIndex);
|
||||||
|
restorationState.setShouldRestoreAssetPosition(true);
|
||||||
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
|
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/models/download/livephotos_medatada.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|||||||
+12
@@ -31,6 +31,18 @@ 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 currentAssetExifProvider = FutureProvider.autoDispose((ref) {
|
||||||
final currentAsset = ref.watch(currentAssetNotifier);
|
final currentAsset = ref.watch(currentAssetNotifier);
|
||||||
if (currentAsset == null) {
|
if (currentAsset == null) {
|
||||||
@@ -753,10 +753,17 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
|
|||||||
DriftActivitiesRoute({
|
DriftActivitiesRoute({
|
||||||
Key? key,
|
Key? key,
|
||||||
required RemoteAlbum album,
|
required RemoteAlbum album,
|
||||||
|
String? assetId,
|
||||||
|
String? assetName,
|
||||||
List<PageRouteInfo>? children,
|
List<PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
DriftActivitiesRoute.name,
|
DriftActivitiesRoute.name,
|
||||||
args: DriftActivitiesRouteArgs(key: key, album: album),
|
args: DriftActivitiesRouteArgs(
|
||||||
|
key: key,
|
||||||
|
album: album,
|
||||||
|
assetId: assetId,
|
||||||
|
assetName: assetName,
|
||||||
|
),
|
||||||
initialChildren: children,
|
initialChildren: children,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -766,21 +773,35 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
|
|||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<DriftActivitiesRouteArgs>();
|
final args = data.argsAs<DriftActivitiesRouteArgs>();
|
||||||
return DriftActivitiesPage(key: args.key, album: args.album);
|
return DriftActivitiesPage(
|
||||||
|
key: args.key,
|
||||||
|
album: args.album,
|
||||||
|
assetId: args.assetId,
|
||||||
|
assetName: args.assetName,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class DriftActivitiesRouteArgs {
|
class DriftActivitiesRouteArgs {
|
||||||
const DriftActivitiesRouteArgs({this.key, required this.album});
|
const DriftActivitiesRouteArgs({
|
||||||
|
this.key,
|
||||||
|
required this.album,
|
||||||
|
this.assetId,
|
||||||
|
this.assetName,
|
||||||
|
});
|
||||||
|
|
||||||
final Key? key;
|
final Key? key;
|
||||||
|
|
||||||
final RemoteAlbum album;
|
final RemoteAlbum album;
|
||||||
|
|
||||||
|
final String? assetId;
|
||||||
|
|
||||||
|
final String? assetName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'DriftActivitiesRouteArgs{key: $key, album: $album}';
|
return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ enum ActionButtonType {
|
|||||||
iconData: Icons.info_outline,
|
iconData: Icons.info_outline,
|
||||||
iconColor: context.originalTheme?.iconTheme.color,
|
iconColor: context.originalTheme?.iconTheme.color,
|
||||||
menuItem: true,
|
menuItem: true,
|
||||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()),
|
||||||
),
|
),
|
||||||
ActionButtonType.viewInTimeline => BaseActionButton(
|
ActionButtonType.viewInTimeline => BaseActionButton(
|
||||||
label: 'view_in_timeline'.tr(),
|
label: 'view_in_timeline'.tr(),
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class ActivityTextField extends HookConsumerWidget {
|
|||||||
prefixIcon: user != null
|
prefixIcon: user != null
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||||
child: UserCircleAvatar(user: user, size: 30, radius: 15),
|
child: UserCircleAvatar(user: user, size: 30),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
suffixIcon: Padding(
|
suffixIcon: Padding(
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ActivityTile extends HookConsumerWidget {
|
|||||||
child: Icon(Icons.thumb_up, color: context.primaryColor),
|
child: Icon(Icons.thumb_up, color: context.primaryColor),
|
||||||
)
|
)
|
||||||
: isBottomSheet
|
: isBottomSheet
|
||||||
? UserCircleAvatar(user: activity.user, size: 30, radius: 15)
|
? UserCircleAvatar(user: activity.user, size: 30)
|
||||||
: UserCircleAvatar(user: activity.user),
|
: UserCircleAvatar(user: activity.user),
|
||||||
title: _ActivityTitle(
|
title: _ActivityTitle(
|
||||||
userName: activity.user.name,
|
userName: activity.user.name,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class CommentBubble extends ConsumerWidget {
|
|||||||
// avatar (hidden for own messages)
|
// avatar (hidden for own messages)
|
||||||
Widget avatar = const SizedBox.shrink();
|
Widget avatar = const SizedBox.shrink();
|
||||||
if (!isOwn) {
|
if (!isOwn) {
|
||||||
avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14);
|
avatar = UserCircleAvatar(user: activity.user, size: 28);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thumbnail with tappable behavior and optional heart overlay
|
// Thumbnail with tappable behavior and optional heart overlay
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget {
|
|||||||
itemBuilder: ((context, index) {
|
itemBuilder: ((context, index) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 4.0),
|
padding: const EdgeInsets.only(right: 4.0),
|
||||||
child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true),
|
child: UserCircleAvatar(user: sharedUsers[index], size: 36, hasBorder: true),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
itemCount: sharedUsers.length,
|
itemCount: sharedUsers.length,
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
children: [
|
children: [
|
||||||
IconButton(onPressed: () => context.pop(), icon: const Icon(Icons.close, size: 20)),
|
IconButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
icon: Icon(Icons.close, size: 20, color: context.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -154,15 +157,12 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text("backup_controller_page_server_storage".tr(), style: context.textTheme.labelLarge),
|
||||||
"backup_controller_page_server_storage",
|
|
||||||
style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500),
|
|
||||||
).tr(),
|
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
minHeight: 10.0,
|
minHeight: 10.0,
|
||||||
value: percentage,
|
value: percentage,
|
||||||
@@ -264,13 +264,13 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
color: context.colorScheme.surface,
|
color: context.colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||||
),
|
),
|
||||||
margin: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
margin: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const AppBarProfileInfoBox(),
|
const AppBarProfileInfoBox(),
|
||||||
const Divider(height: 3),
|
Divider(thickness: 4, color: context.colorScheme.surfaceContainer),
|
||||||
buildStorageInformation(),
|
buildStorageInformation(),
|
||||||
const Divider(height: 3),
|
Divider(thickness: 4, color: context.colorScheme.surfaceContainer),
|
||||||
const AppBarServerInfo(),
|
const AppBarServerInfo(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final userImage = UserCircleAvatar(radius: 22, size: 44, user: user);
|
final userImage = UserCircleAvatar(size: 44, user: user, hasBorder: true);
|
||||||
|
|
||||||
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
|
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
|
||||||
return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20));
|
return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20));
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AppBarServerInfo extends HookConsumerWidget {
|
|||||||
const divider = Divider(thickness: 1);
|
const divider = Divider(thickness: 1);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -109,7 +109,7 @@ class _ServerInfoItem extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: contentFontSize,
|
fontSize: contentFontSize,
|
||||||
color: context.colorScheme.onSurfaceSecondary,
|
color: context.colorScheme.onSurfaceSecondary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w500,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.end,
|
textAlign: TextAlign.end,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
? const Icon(Icons.face_outlined, size: widgetSize)
|
? const Icon(Icons.face_outlined, size: widgetSize)
|
||||||
: Semantics(
|
: Semantics(
|
||||||
label: "logged_in_as".tr(namedArgs: {"user": user.name}),
|
label: "logged_in_as".tr(namedArgs: {"user": user.name}),
|
||||||
child: UserCircleAvatar(radius: 17, size: 31, user: user),
|
child: UserCircleAvatar(size: 32, user: user),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -46,45 +48,42 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||||
|
|
||||||
return SliverAnimatedOpacity(
|
return SliverIgnorePointer(
|
||||||
duration: Durations.medium1,
|
ignoring: isMultiSelectEnabled,
|
||||||
opacity: isMultiSelectEnabled ? 0 : 1,
|
sliver: SliverAnimatedOpacity(
|
||||||
sliver: SliverAppBar(
|
duration: Durations.medium1,
|
||||||
backgroundColor: context.colorScheme.surface,
|
opacity: isMultiSelectEnabled ? 0 : 1,
|
||||||
surfaceTintColor: context.colorScheme.surfaceTint,
|
sliver: SliverAppBar(
|
||||||
elevation: 0,
|
backgroundColor: context.colorScheme.surface,
|
||||||
scrolledUnderElevation: 1.0,
|
surfaceTintColor: context.colorScheme.surfaceTint,
|
||||||
floating: floating,
|
elevation: 0,
|
||||||
pinned: pinned,
|
scrolledUnderElevation: 1.0,
|
||||||
snap: snap,
|
floating: floating,
|
||||||
expandedHeight: expandedHeight,
|
pinned: pinned,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
snap: snap,
|
||||||
automaticallyImplyLeading: false,
|
expandedHeight: expandedHeight,
|
||||||
centerTitle: false,
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||||
title: title ?? const _ImmichLogoWithText(),
|
automaticallyImplyLeading: false,
|
||||||
actions: [
|
centerTitle: false,
|
||||||
if (isCasting && !isReadonlyModeEnabled)
|
title: title ?? const _ImmichLogoWithText(),
|
||||||
Padding(
|
actions: [
|
||||||
padding: const EdgeInsets.only(right: 12),
|
const _SyncStatusIndicator(),
|
||||||
child: IconButton(
|
if (isCasting && !isReadonlyModeEnabled)
|
||||||
onPressed: () {
|
IconButton(
|
||||||
showDialog(context: context, builder: (context) => const CastDialog());
|
onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()),
|
||||||
},
|
|
||||||
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
|
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
|
||||||
),
|
),
|
||||||
),
|
if (actions != null) ...actions!,
|
||||||
const _SyncStatusIndicator(),
|
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
|
||||||
if (actions != null)
|
IconButton(
|
||||||
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
|
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
|
||||||
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
|
icon: const Icon(Icons.palette_rounded),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.palette_rounded),
|
if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(),
|
||||||
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
|
const _ProfileIndicator(),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
if (showUploadButton && !isReadonlyModeEnabled)
|
],
|
||||||
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),
|
),
|
||||||
const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,27 +93,14 @@ class _ImmichLogoWithText extends StatelessWidget {
|
|||||||
const _ImmichLogoWithText();
|
const _ImmichLogoWithText();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => AnimatedOpacity(
|
||||||
return Builder(
|
opacity: IconTheme.of(context).opacity ?? 1,
|
||||||
builder: (BuildContext context) {
|
duration: kThemeChangeDuration,
|
||||||
return Row(
|
child: SvgPicture.asset(
|
||||||
children: [
|
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
|
||||||
Builder(
|
height: 40,
|
||||||
builder: (context) {
|
),
|
||||||
return Padding(
|
);
|
||||||
padding: const EdgeInsets.only(top: 3.0),
|
|
||||||
child: SvgPicture.asset(
|
|
||||||
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
|
|
||||||
height: 40,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProfileIndicator extends ConsumerWidget {
|
class _ProfileIndicator extends ConsumerWidget {
|
||||||
@@ -126,7 +112,7 @@ class _ProfileIndicator extends ConsumerWidget {
|
|||||||
final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user));
|
final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user));
|
||||||
final serverInfoState = ref.watch(serverInfoProvider);
|
final serverInfoState = ref.watch(serverInfoProvider);
|
||||||
|
|
||||||
const widgetSize = 30.0;
|
const widgetSize = 32.0;
|
||||||
|
|
||||||
// TODO: remove this when update Flutter version newer than 3.35.7
|
// TODO: remove this when update Flutter version newer than 3.35.7
|
||||||
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
|
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
|
||||||
@@ -146,27 +132,23 @@ class _ProfileIndicator extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return InkWell(
|
return IconButton(
|
||||||
onTap: () => showDialog(
|
onPressed: () => showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: false,
|
useRootNavigator: false,
|
||||||
barrierDismissible: !isIpad,
|
barrierDismissible: !isIpad,
|
||||||
builder: (ctx) => const ImmichAppBarDialog(),
|
builder: (ctx) => const ImmichAppBarDialog(),
|
||||||
),
|
),
|
||||||
onLongPress: () => toggleReadonlyMode(),
|
onLongPress: () => toggleReadonlyMode(),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
icon: Badge(
|
||||||
child: Badge(
|
label: _BadgeLabel(
|
||||||
label: Container(
|
Icon(
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.isDarkTheme ? Colors.black : Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(widgetSize / 2),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.info,
|
Icons.info,
|
||||||
color: serverInfoState.versionStatus == VersionStatus.error
|
color: serverInfoState.versionStatus == VersionStatus.error
|
||||||
? context.colorScheme.error
|
? context.colorScheme.error
|
||||||
: context.primaryColor,
|
: context.primaryColor,
|
||||||
size: widgetSize / 2,
|
size: widgetSize / 2,
|
||||||
|
semanticLabel: 'new_version_available'.tr(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
@@ -177,7 +159,16 @@ class _ProfileIndicator extends ConsumerWidget {
|
|||||||
? const Icon(Icons.face_outlined, size: widgetSize)
|
? const Icon(Icons.face_outlined, size: widgetSize)
|
||||||
: Semantics(
|
: Semantics(
|
||||||
label: "logged_in_as".tr(namedArgs: {"user": user.name}),
|
label: "logged_in_as".tr(namedArgs: {"user": user.name}),
|
||||||
child: AbsorbPointer(child: UserCircleAvatar(radius: 17, size: 31, user: user)),
|
child: AbsorbPointer(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => UserCircleAvatar(
|
||||||
|
size: 34,
|
||||||
|
user: user,
|
||||||
|
opacity: IconTheme.of(context).opacity ?? 1,
|
||||||
|
hasBorder: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -193,10 +184,9 @@ class _BackupIndicator extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final indicatorIcon = _getBackupBadgeIcon(context, ref);
|
final indicatorIcon = _getBackupBadgeIcon(context, ref);
|
||||||
|
|
||||||
return InkWell(
|
return IconButton(
|
||||||
onTap: () => context.pushRoute(const DriftBackupRoute()),
|
onPressed: () => context.pushRoute(const DriftBackupRoute()),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
icon: Badge(
|
||||||
child: Badge(
|
|
||||||
label: indicatorIcon,
|
label: indicatorIcon,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
alignment: Alignment.bottomRight,
|
alignment: Alignment.bottomRight,
|
||||||
@@ -278,12 +268,14 @@ class _BadgeLabel extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final opacity = IconTheme.of(context).opacity ?? 1;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: _kBadgeWidgetSize / 2,
|
width: _kBadgeWidgetSize / 2,
|
||||||
height: _kBadgeWidgetSize / 2,
|
height: _kBadgeWidgetSize / 2,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: backgroundColor ?? context.colorScheme.surfaceContainer,
|
color: (backgroundColor ?? context.colorScheme.surfaceContainer).withValues(alpha: opacity),
|
||||||
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)),
|
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3 * opacity)),
|
||||||
borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2),
|
borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2),
|
||||||
),
|
),
|
||||||
child: indicator,
|
child: indicator,
|
||||||
@@ -346,23 +338,30 @@ class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> with
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return Padding(
|
||||||
animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]),
|
padding: const EdgeInsets.all(8),
|
||||||
builder: (context, child) {
|
child: TweenAnimationBuilder<double>(
|
||||||
return Padding(
|
tween: Tween(end: IconTheme.of(context).opacity ?? 1),
|
||||||
padding: EdgeInsets.only(right: isSyncing ? 16 : 0),
|
duration: kThemeChangeDuration,
|
||||||
child: Transform.scale(
|
builder: (context, opacity, child) {
|
||||||
scale: isSyncing ? 1.0 : _dismissalAnimation.value,
|
return AnimatedBuilder(
|
||||||
child: Opacity(
|
animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]),
|
||||||
opacity: isSyncing ? 1.0 : _dismissalAnimation.value,
|
builder: (context, child) {
|
||||||
child: Transform.rotate(
|
final dismissalValue = isSyncing ? 1.0 : _dismissalAnimation.value;
|
||||||
angle: _rotationAnimation.value * 2 * 3.14159 * -1, // Rotate counter-clockwise
|
return IconTheme(
|
||||||
child: Icon(Icons.sync, size: 24, color: context.primaryColor),
|
data: IconTheme.of(context).copyWith(opacity: opacity * dismissalValue),
|
||||||
),
|
child: Transform(
|
||||||
),
|
alignment: Alignment.center,
|
||||||
),
|
transform: Matrix4.identity()
|
||||||
);
|
..scaleByDouble(dismissalValue, dismissalValue, dismissalValue, 1.0)
|
||||||
},
|
..rotateZ(-_rotationAnimation.value * 2 * math.pi),
|
||||||
|
child: const Icon(Icons.sync),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'dart:io';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
|
||||||
@@ -254,22 +254,9 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
|
|||||||
),
|
),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: widget.onEditTitle,
|
onTap: widget.onEditTitle,
|
||||||
child: SizedBox(
|
child: LayoutBuilder(
|
||||||
width: double.infinity,
|
builder: (context, constraints) =>
|
||||||
child: SingleChildScrollView(
|
_DynamicText(text: currentAlbum.name, maxWidth: constraints.maxWidth),
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Text(
|
|
||||||
currentAlbum.name,
|
|
||||||
maxLines: 1,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (currentAlbum.description.isNotEmpty)
|
if (currentAlbum.description.isNotEmpty)
|
||||||
@@ -549,3 +536,46 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DynamicText extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final double maxWidth;
|
||||||
|
|
||||||
|
const _DynamicText({required this.text, required this.maxWidth});
|
||||||
|
|
||||||
|
static const _baseTextStyle = TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)],
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
|
||||||
|
int _lineCount(double fontSize) {
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: _baseTextStyle.copyWith(fontSize: fontSize),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout(maxWidth: maxWidth);
|
||||||
|
return textPainter.computeLineMetrics().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _fontSize() {
|
||||||
|
final fontSizes = [44.0, 36.0];
|
||||||
|
for (final fontSize in fontSizes) {
|
||||||
|
final lineCount = _lineCount(fontSize);
|
||||||
|
if (lineCount == 1) {
|
||||||
|
return fontSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(text, style: _baseTextStyle.copyWith(fontSize: _fontSize()), maxLines: 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,49 +8,52 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.
|
|||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class UserCircleAvatar extends ConsumerWidget {
|
class UserCircleAvatar extends ConsumerWidget {
|
||||||
final UserDto user;
|
final UserDto user;
|
||||||
double radius;
|
|
||||||
double size;
|
double size;
|
||||||
bool hasBorder;
|
bool hasBorder;
|
||||||
|
double opacity;
|
||||||
|
|
||||||
UserCircleAvatar({super.key, this.radius = 22, this.size = 44, this.hasBorder = false, required this.user});
|
UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final userAvatarColor = user.avatarColor.toColor();
|
final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity);
|
||||||
final profileImageUrl =
|
final profileImageUrl =
|
||||||
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
|
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
|
||||||
|
|
||||||
|
final textColor = (user.avatarColor.toColor().computeLuminance() > 0.5 ? Colors.black : Colors.white).withValues(
|
||||||
|
alpha: opacity,
|
||||||
|
);
|
||||||
|
|
||||||
final textIcon = DefaultTextStyle(
|
final textIcon = DefaultTextStyle(
|
||||||
style: TextStyle(
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: textColor),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 12,
|
|
||||||
color: userAvatarColor.computeLuminance() > 0.5 ? Colors.black : Colors.white,
|
|
||||||
),
|
|
||||||
child: Text(user.name[0].toUpperCase()),
|
child: Text(user.name[0].toUpperCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: user.name,
|
message: user.name,
|
||||||
child: Container(
|
child: UnconstrainedBox(
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
shape: BoxShape.circle,
|
width: size,
|
||||||
border: hasBorder ? Border.all(color: Colors.grey[500]!, width: 1) : null,
|
height: size,
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: CircleAvatar(
|
color: userAvatarColor,
|
||||||
backgroundColor: userAvatarColor,
|
shape: BoxShape.circle,
|
||||||
radius: radius,
|
border: hasBorder ? Border.all(color: userAvatarColor.withValues(alpha: opacity), width: 1.5) : null,
|
||||||
|
),
|
||||||
child: user.hasProfileImage
|
child: user.hasProfileImage
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(50)),
|
borderRadius: BorderRadius.all(Radius.circular(size / 2)),
|
||||||
child: Image(
|
child: Image(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
image: RemoteImageProvider(url: profileImageUrl),
|
image: RemoteImageProvider(url: profileImageUrl),
|
||||||
errorBuilder: (context, error, stackTrace) => textIcon,
|
errorBuilder: (context, error, stackTrace) => textIcon,
|
||||||
|
color: Colors.white.withValues(alpha: opacity),
|
||||||
|
colorBlendMode: BlendMode.modulate,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: textIcon,
|
: Center(child: textIcon),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user