Compare commits
43 Commits
refactor/m
...
chore/pnpm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a24186e883 | ||
|
|
85c348d87c | ||
|
|
b0778dcc49 | ||
|
|
7b1011c091 | ||
|
|
494384ca8a | ||
|
|
0afe49cd8a | ||
|
|
678ea38f2f | ||
|
|
23cce1ea91 | ||
|
|
0bfc8beec1 | ||
|
|
0992d50699 | ||
|
|
d4f2b43f64 | ||
|
|
f343b0e58f | ||
|
|
a8b4a5e856 | ||
|
|
e7e030279b | ||
|
|
9ff664ed36 | ||
|
|
e00556a34a | ||
|
|
a313e4338e | ||
|
|
257b0c74af | ||
|
|
3d515f5072 | ||
|
|
ec01db5c8b | ||
|
|
cd6d8fcdfe | ||
|
|
1198311d64 | ||
|
|
1a4eab9655 | ||
|
|
1926c90780 | ||
|
|
4d5975b717 | ||
|
|
8cbd6b29c4 | ||
|
|
8c1b630a2b | ||
|
|
c961d2aaf7 | ||
|
|
41c75dc93e | ||
|
|
f92247c99b | ||
|
|
53f9fc2d1c | ||
|
|
bede19a3ca | ||
|
|
aefa62b234 | ||
|
|
b3fb831994 | ||
|
|
0d60199514 | ||
|
|
54960157c0 | ||
|
|
244d097d01 | ||
|
|
adb55f3726 | ||
|
|
5d2777a5c6 | ||
|
|
24db881c14 | ||
|
|
f09bed9ad2 | ||
|
|
e29cc66361 | ||
|
|
669b765662 |
@@ -49,10 +49,11 @@ fix_permissions() {
|
|||||||
|
|
||||||
log "Fixing permissions for ${IMMICH_WORKSPACE}"
|
log "Fixing permissions for ${IMMICH_WORKSPACE}"
|
||||||
|
|
||||||
run_cmd sudo find "${IMMICH_WORKSPACE}/server/upload" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres/*" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres" -exec chown node {} +
|
|
||||||
|
|
||||||
# Change ownership for directories that exist
|
# Change ownership for directories that exist
|
||||||
for dir in "${IMMICH_WORKSPACE}/.vscode" \
|
for dir in "${IMMICH_WORKSPACE}/.vscode" \
|
||||||
|
"${IMMICH_WORKSPACE}/server/upload" \
|
||||||
|
"${IMMICH_WORKSPACE}/.pnpm-store" \
|
||||||
|
"${IMMICH_WORKSPACE}/.github/node_modules" \
|
||||||
"${IMMICH_WORKSPACE}/cli/node_modules" \
|
"${IMMICH_WORKSPACE}/cli/node_modules" \
|
||||||
"${IMMICH_WORKSPACE}/e2e/node_modules" \
|
"${IMMICH_WORKSPACE}/e2e/node_modules" \
|
||||||
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
|
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
|
||||||
|
|||||||
@@ -8,21 +8,13 @@ services:
|
|||||||
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ..:/workspaces/immich
|
- ..:/workspaces/immich
|
||||||
- cli_node_modules:/workspaces/immich/cli/node_modules
|
|
||||||
- e2e_node_modules:/workspaces/immich/e2e/node_modules
|
|
||||||
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
|
|
||||||
- server_node_modules:/workspaces/immich/server/node_modules
|
|
||||||
- web_node_modules:/workspaces/immich/web/node_modules
|
|
||||||
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||||
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
|
||||||
immich-web:
|
immich-web:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
|
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
|
|
||||||
database:
|
database:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
environment: !override
|
environment: !override
|
||||||
@@ -33,17 +25,10 @@ services:
|
|||||||
POSTGRES_HOST_AUTH_METHOD: md5
|
POSTGRES_HOST_AUTH_METHOD: md5
|
||||||
volumes:
|
volumes:
|
||||||
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
|
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Node modules for each service to avoid conflicts and ensure consistent dependencies
|
# Node modules for each service to avoid conflicts and ensure consistent dependencies
|
||||||
cli_node_modules:
|
|
||||||
e2e_node_modules:
|
|
||||||
open_api_node_modules:
|
|
||||||
server_node_modules:
|
|
||||||
web_node_modules:
|
|
||||||
upload1-devcontainer-volume:
|
upload1-devcontainer-volume:
|
||||||
upload2-devcontainer-volume:
|
upload2-devcontainer-volume:
|
||||||
postgres-devcontainer-volume:
|
postgres-devcontainer-volume:
|
||||||
|
|||||||
@@ -3,15 +3,20 @@
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source /immich-devcontainer/container-common.sh
|
source /immich-devcontainer/container-common.sh
|
||||||
|
|
||||||
|
log "Preparing Immich Nest API Server"
|
||||||
|
log ""
|
||||||
|
export CI=1
|
||||||
|
run_cmd pnpm --filter immich install
|
||||||
|
|
||||||
log "Starting Nest API Server"
|
log "Starting Nest API Server"
|
||||||
log ""
|
log ""
|
||||||
cd "${IMMICH_WORKSPACE}/server" || (
|
cd "${IMMICH_WORKSPACE}/server" || (
|
||||||
log "Immich workspace not found"
|
log "Immich workspace not found"jj
|
||||||
exit 1
|
exit 1
|
||||||
)
|
)
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch
|
run_cmd pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch
|
||||||
log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
|
log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source /immich-devcontainer/container-common.sh
|
source /immich-devcontainer/container-common.sh
|
||||||
|
|
||||||
|
export CI=1
|
||||||
|
log "Preparing Immich Web Frontend"
|
||||||
|
log ""
|
||||||
|
run_cmd pnpm --filter @immich/sdk install
|
||||||
|
run_cmd pnpm --filter @immich/sdk build
|
||||||
|
run_cmd pnpm --filter immich-web install
|
||||||
|
|
||||||
log "Starting Immich Web Frontend"
|
log "Starting Immich Web Frontend"
|
||||||
log ""
|
log ""
|
||||||
cd "${IMMICH_WORKSPACE}/web" || (
|
cd "${IMMICH_WORKSPACE}/web" || (
|
||||||
@@ -16,7 +23,7 @@ until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_
|
|||||||
done
|
done
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
|
run_cmd pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port "${DEV_PORT}"
|
||||||
log "Web crashed with exit code $?. Respawning in 3s ..."
|
log "Web crashed with exit code $?. Respawning in 3s ..."
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ source /immich-devcontainer/container-common.sh
|
|||||||
log "Setting up Immich dev container..."
|
log "Setting up Immich dev container..."
|
||||||
fix_permissions
|
fix_permissions
|
||||||
|
|
||||||
log "Installing npm dependencies (node_modules)..."
|
|
||||||
install_dependencies
|
|
||||||
|
|
||||||
log "Setup complete, please wait while backend and frontend services automatically start"
|
log "Setup complete, please wait while backend and frontend services automatically start"
|
||||||
log
|
log
|
||||||
log "If necessary, the services may be manually started using"
|
log "If necessary, the services may be manually started using"
|
||||||
|
|||||||
28
.github/package-lock.json
generated
vendored
28
.github/package-lock.json
generated
vendored
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": ".github",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"devDependencies": {
|
|
||||||
"prettier": "^3.5.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prettier": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin/prettier.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
.github/workflows/cli.yml
vendored
25
.github/workflows/cli.yml
vendored
@@ -33,21 +33,24 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# Setup .npmrc file to publish to npm
|
- name: Setup pnpm
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.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'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Prepare SDK
|
- name: Setup typescript-sdk
|
||||||
run: npm ci --prefix ../open-api/typescript-sdk/
|
run: pnpm install && pnpm run build
|
||||||
- name: Build SDK
|
working-directory: ./open-api/typescript-sdk
|
||||||
run: npm run build --prefix ../open-api/typescript-sdk/
|
|
||||||
- run: npm ci
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: npm run build
|
- run: pnpm build
|
||||||
- run: npm publish
|
- run: pnpm publish
|
||||||
if: ${{ github.event_name == 'release' }}
|
if: ${{ github.event_name == 'release' }}
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|||||||
4
.github/workflows/close-duplicates.yml
vendored
4
.github/workflows/close-duplicates.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
gh api graphql \
|
gh api graphql \
|
||||||
-f issueId="$NODE_ID" \
|
-f issueId="$NODE_ID" \
|
||||||
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
|
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
|
||||||
-f query='
|
-f query='
|
||||||
mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
|
mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
|
||||||
addComment(input: {
|
addComment(input: {
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
gh api graphql \
|
gh api graphql \
|
||||||
-f discussionId="$NODE_ID" \
|
-f discussionId="$NODE_ID" \
|
||||||
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
|
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
|
||||||
-f query='
|
-f query='
|
||||||
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
|
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
|
||||||
addDiscussionComment(input: {
|
addDiscussionComment(input: {
|
||||||
|
|||||||
15
.github/workflows/docs-build.yml
vendored
15
.github/workflows/docs-build.yml
vendored
@@ -55,21 +55,24 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './docs/.nvmrc'
|
node-version-file: './docs/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Run npm install
|
- name: Run install
|
||||||
run: npm ci
|
run: pnpm install
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: npm run format
|
run: pnpm format
|
||||||
|
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Upload build output
|
- name: Upload build output
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
|||||||
4
.github/workflows/fix-format.yml
vendored
4
.github/workflows/fix-format.yml
vendored
@@ -32,8 +32,8 @@ jobs:
|
|||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Fix formatting
|
- name: Fix formatting
|
||||||
run: make install-all && make format-all
|
run: make install-all && make format-all
|
||||||
|
|||||||
13
.github/workflows/sdk.yml
vendored
13
.github/workflows/sdk.yml
vendored
@@ -20,18 +20,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.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'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: pnpm build
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: npm publish
|
run: pnpm publish
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|||||||
459
.github/workflows/test.yml
vendored
459
.github/workflows/test.yml
vendored
@@ -4,13 +4,10 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-job:
|
pre-job:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -32,7 +29,6 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- id: found_paths
|
- id: found_paths
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||||
with:
|
with:
|
||||||
@@ -58,11 +54,9 @@ jobs:
|
|||||||
- '.github/workflows/test.yml'
|
- '.github/workflows/test.yml'
|
||||||
.github:
|
.github:
|
||||||
- '.github/**'
|
- '.github/**'
|
||||||
|
|
||||||
- name: Check if we should force jobs to run
|
- name: Check if we should force jobs to run
|
||||||
id: should_force
|
id: should_force
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
|
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
server-unit-tests:
|
server-unit-tests:
|
||||||
name: Test & Lint Server
|
name: Test & Lint Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -73,39 +67,33 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
- name: Run package manager install
|
||||||
- name: Run npm install
|
run: pnpm install
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: npm run lint
|
run: pnpm lint
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run formatter
|
- name: Run formatter
|
||||||
run: npm run format
|
run: pnpm format
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run tsc
|
- name: Run tsc
|
||||||
run: npm run check
|
run: pnpm check
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run small tests & coverage
|
- name: Run small tests & coverage
|
||||||
run: npm test
|
run: pnpm test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
cli-unit-tests:
|
cli-unit-tests:
|
||||||
name: Unit Test CLI
|
name: Unit Test CLI
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -116,43 +104,36 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Setup typescript-sdk
|
- name: Setup typescript-sdk
|
||||||
run: npm ci && npm run build
|
run: pnpm install && pnpm run build
|
||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci
|
run: pnpm install
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: npm run lint
|
run: pnpm lint
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run formatter
|
- name: Run formatter
|
||||||
run: npm run format
|
run: pnpm format
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run tsc
|
- name: Run tsc
|
||||||
run: npm run check
|
run: pnpm check
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run unit tests & coverage
|
- name: Run unit tests & coverage
|
||||||
run: npm run test
|
run: pnpm test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
cli-unit-tests-win:
|
cli-unit-tests-win:
|
||||||
name: Unit Test CLI (Windows)
|
name: Unit Test CLI (Windows)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -163,36 +144,31 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Setup typescript-sdk
|
- name: Setup typescript-sdk
|
||||||
run: npm ci && npm run build
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Skip linter & formatter in Windows test.
|
# Skip linter & formatter in Windows test.
|
||||||
- name: Run tsc
|
- name: Run tsc
|
||||||
run: npm run check
|
run: pnpm check
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run unit tests & coverage
|
- name: Run unit tests & coverage
|
||||||
run: npm run test
|
run: pnpm test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
web-lint:
|
web-lint:
|
||||||
name: Lint Web
|
name: Lint Web
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -203,39 +179,33 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Run setup typescript-sdk
|
- name: Run setup typescript-sdk
|
||||||
run: npm ci && npm run build
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
- name: Run pnpm install
|
||||||
- name: Run npm install
|
run: pnpm rebuild && pnpm install --frozen-lockfile
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: npm run lint:p
|
run: pnpm lint:p
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run formatter
|
- name: Run formatter
|
||||||
run: npm run format
|
run: pnpm format
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run svelte checks
|
- name: Run svelte checks
|
||||||
run: npm run check:svelte
|
run: pnpm check:svelte
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
web-unit-tests:
|
web-unit-tests:
|
||||||
name: Test Web
|
name: Test Web
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -246,35 +216,30 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Run setup typescript-sdk
|
- name: Run setup typescript-sdk
|
||||||
run: npm ci && npm run build
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
|
||||||
- name: Run npm install
|
- name: Run npm install
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tsc
|
- name: Run tsc
|
||||||
run: npm run check:typescript
|
run: pnpm check:typescript
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run unit tests & coverage
|
- name: Run unit tests & coverage
|
||||||
run: npm run test
|
run: pnpm test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
i18n-tests:
|
i18n-tests:
|
||||||
name: Test i18n
|
name: Test i18n
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -287,27 +252,24 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm --prefix=web ci
|
run: pnpm --filter=immich-web install --frozen-lockfile
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
run: npm --prefix=web run format:i18n
|
run: pnpm --filter=immich-web format:i18n
|
||||||
|
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||||
id: verify-changed-files
|
id: verify-changed-files
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
i18n/**
|
i18n/**
|
||||||
|
|
||||||
- name: Verify files have not changed
|
- name: Verify files have not changed
|
||||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -316,7 +278,6 @@ jobs:
|
|||||||
echo "ERROR: i18n files not up to date!"
|
echo "ERROR: i18n files not up to date!"
|
||||||
echo "Changed files: ${CHANGED_FILES}"
|
echo "Changed files: ${CHANGED_FILES}"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
e2e-tests-lint:
|
e2e-tests-lint:
|
||||||
name: End-to-End Lint
|
name: End-to-End Lint
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -327,41 +288,35 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Run setup typescript-sdk
|
- name: Run setup typescript-sdk
|
||||||
run: npm ci && npm run build
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: npm run lint
|
run: pnpm lint
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run formatter
|
- name: Run formatter
|
||||||
run: npm run format
|
run: pnpm format
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run tsc
|
- name: Run tsc
|
||||||
run: npm run check
|
run: pnpm check
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
server-medium-tests:
|
server-medium-tests:
|
||||||
name: Medium Tests (Server)
|
name: Medium Tests (Server)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -372,27 +327,24 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
- name: Run pnpm install
|
||||||
- name: Run npm install
|
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run medium tests
|
- name: Run medium tests
|
||||||
run: npm run test:medium
|
run: pnpm test:medium
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
e2e-tests-server-cli:
|
e2e-tests-server-cli:
|
||||||
name: End-to-End Tests (Server & CLI)
|
name: End-to-End Tests (Server & CLI)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -406,43 +358,41 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Run setup typescript-sdk
|
- name: Run setup typescript-sdk
|
||||||
run: npm ci && npm run build
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
- name: Run setup web
|
||||||
|
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
|
||||||
|
working-directory: ./web
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
- name: Run setup cli
|
- name: Run setup cli
|
||||||
run: npm ci && npm run build
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Docker build
|
- name: Docker build
|
||||||
run: docker compose build
|
run: docker compose build
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run e2e tests (api & cli)
|
- name: Run e2e tests (api & cli)
|
||||||
run: npm run test
|
run: pnpm test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
e2e-tests-web:
|
e2e-tests-web:
|
||||||
name: End-to-End Tests (Web)
|
name: End-to-End Tests (Web)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -456,42 +406,36 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Run setup typescript-sdk
|
- name: Run setup typescript-sdk
|
||||||
run: npm ci && npm run build
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install chromium --only-shell
|
run: npx playwright install chromium --only-shell
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Docker build
|
- name: Docker build
|
||||||
run: docker compose build
|
run: docker compose build
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
- name: Run e2e tests (web)
|
- name: Run e2e tests (web)
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
success-check-e2e:
|
success-check-e2e:
|
||||||
name: End-to-End Tests Success
|
name: End-to-End Tests Success
|
||||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||||
@@ -502,7 +446,6 @@ jobs:
|
|||||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||||
with:
|
with:
|
||||||
needs: ${{ toJSON(needs) }}
|
needs: ${{ toJSON(needs) }}
|
||||||
|
|
||||||
mobile-unit-tests:
|
mobile-unit-tests:
|
||||||
name: Unit Test Mobile
|
name: Unit Test Mobile
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -514,21 +457,19 @@ jobs:
|
|||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version-file: ./mobile/pubspec.yaml
|
flutter-version-file: ./mobile/pubspec.yaml
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Generate translation file
|
- name: Generate translation file
|
||||||
run: make translation
|
run: make translation
|
||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
run: flutter test -j 1
|
run: flutter test -j 1
|
||||||
|
|
||||||
ml-unit-tests:
|
ml-unit-tests:
|
||||||
name: Unit Test ML
|
name: Unit Test ML
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -543,7 +484,6 @@ jobs:
|
|||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
@@ -566,7 +506,6 @@ jobs:
|
|||||||
- name: Run tests and coverage
|
- name: Run tests and coverage
|
||||||
run: |
|
run: |
|
||||||
uv run pytest --cov=immich_ml --cov-report term-missing
|
uv run pytest --cov=immich_ml --cov-report term-missing
|
||||||
|
|
||||||
github-files-formatting:
|
github-files-formatting:
|
||||||
name: .github Files Formatting
|
name: .github Files Formatting
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
@@ -577,27 +516,24 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./.github
|
working-directory: ./.github
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './.github/.nvmrc'
|
node-version-file: './.github/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
- name: Run pnpm install
|
||||||
- name: Run npm install
|
run: pnpm install --frozen-lockfile
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run formatter
|
- name: Run formatter
|
||||||
run: npm run format
|
run: pnpm format
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
shellcheck:
|
shellcheck:
|
||||||
name: ShellCheck
|
name: ShellCheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -607,15 +543,11 @@ jobs:
|
|||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run ShellCheck
|
- name: Run ShellCheck
|
||||||
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
|
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
|
||||||
with:
|
with:
|
||||||
ignore_paths: >-
|
ignore_paths: >-
|
||||||
**/open-api/**
|
**/open-api/** **/openapi** **/node_modules/**
|
||||||
**/openapi**
|
|
||||||
**/node_modules/**
|
|
||||||
|
|
||||||
generated-api-up-to-date:
|
generated-api-up-to-date:
|
||||||
name: OpenAPI Clients
|
name: OpenAPI Clients
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -626,23 +558,20 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: npm --prefix=server ci
|
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build the app
|
- name: Build the app
|
||||||
run: npm --prefix=server run build
|
run: pnpm --filter immich build
|
||||||
|
|
||||||
- name: Run API generation
|
- name: Run API generation
|
||||||
run: make open-api
|
run: make open-api
|
||||||
|
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||||
id: verify-changed-files
|
id: verify-changed-files
|
||||||
@@ -651,7 +580,6 @@ jobs:
|
|||||||
mobile/openapi
|
mobile/openapi
|
||||||
open-api/typescript-sdk
|
open-api/typescript-sdk
|
||||||
open-api/immich-openapi-specs.json
|
open-api/immich-openapi-specs.json
|
||||||
|
|
||||||
- name: Verify files have not changed
|
- name: Verify files have not changed
|
||||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -660,7 +588,6 @@ jobs:
|
|||||||
echo "ERROR: Generated files not up to date!"
|
echo "ERROR: Generated files not up to date!"
|
||||||
echo "Changed files: ${CHANGED_FILES}"
|
echo "Changed files: ${CHANGED_FILES}"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
sql-schema-up-to-date:
|
sql-schema-up-to-date:
|
||||||
name: SQL Schema Checks
|
name: SQL Schema Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -674,45 +601,36 @@ jobs:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_DB: immich
|
POSTGRES_DB: immich
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: npm ci
|
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build the app
|
- name: Build the app
|
||||||
run: npm run build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Run existing migrations
|
- name: Run existing migrations
|
||||||
run: npm run migrations:run
|
run: pnpm migrations:run
|
||||||
|
|
||||||
- name: Test npm run schema:reset command works
|
- name: Test npm run schema:reset command works
|
||||||
run: npm run schema:reset
|
run: pnpm schema:reset
|
||||||
|
|
||||||
- name: Generate new migrations
|
- name: Generate new migrations
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: npm run migrations:generate src/TestMigration
|
run: pnpm migrations:generate src/TestMigration
|
||||||
|
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||||
id: verify-changed-files
|
id: verify-changed-files
|
||||||
@@ -728,19 +646,16 @@ jobs:
|
|||||||
echo "Changed files: ${CHANGED_FILES}"
|
echo "Changed files: ${CHANGED_FILES}"
|
||||||
cat ./src/*-TestMigration.ts
|
cat ./src/*-TestMigration.ts
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Run SQL generation
|
- name: Run SQL generation
|
||||||
run: npm run sync:sql
|
run: pnpm sync:sql
|
||||||
env:
|
env:
|
||||||
DB_URL: postgres://postgres:postgres@localhost:5432/immich
|
DB_URL: postgres://postgres:postgres@localhost:5432/immich
|
||||||
|
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||||
id: verify-changed-sql-files
|
id: verify-changed-sql-files
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
server/src/queries
|
server/src/queries
|
||||||
|
|
||||||
- name: Verify SQL files have not changed
|
- name: Verify SQL files have not changed
|
||||||
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
|
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -751,77 +666,77 @@ jobs:
|
|||||||
git diff
|
git diff
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
# mobile-integration-tests:
|
# mobile-integration-tests:
|
||||||
# name: Run mobile end-to-end integration tests
|
# name: Run mobile end-to-end integration tests
|
||||||
# runs-on: macos-latest
|
# runs-on: macos-latest
|
||||||
# steps:
|
# steps:
|
||||||
# - uses: actions/checkout@v4
|
# - uses: actions/checkout@v4
|
||||||
# - uses: actions/setup-java@v3
|
# - uses: actions/setup-java@v3
|
||||||
# with:
|
# with:
|
||||||
# distribution: 'zulu'
|
# distribution: 'zulu'
|
||||||
# java-version: '12.x'
|
# java-version: '12.x'
|
||||||
# cache: 'gradle'
|
# cache: 'gradle'
|
||||||
# - name: Cache android SDK
|
# - name: Cache android SDK
|
||||||
# uses: actions/cache@v3
|
# uses: actions/cache@v3
|
||||||
# id: android-sdk
|
# id: android-sdk
|
||||||
# with:
|
# with:
|
||||||
# key: android-sdk
|
# key: android-sdk
|
||||||
# path: |
|
# path: |
|
||||||
# /usr/local/lib/android/
|
# /usr/local/lib/android/
|
||||||
# ~/.android
|
# ~/.android
|
||||||
# - name: Cache Gradle
|
# - name: Cache Gradle
|
||||||
# uses: actions/cache@v3
|
# uses: actions/cache@v3
|
||||||
# with:
|
# with:
|
||||||
# path: |
|
# path: |
|
||||||
# ./mobile/build/
|
# ./mobile/build/
|
||||||
# ./mobile/android/.gradle/
|
# ./mobile/android/.gradle/
|
||||||
# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
|
# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
|
||||||
# - name: Setup Android SDK
|
# - name: Setup Android SDK
|
||||||
# if: steps.android-sdk.outputs.cache-hit != 'true'
|
# if: steps.android-sdk.outputs.cache-hit != 'true'
|
||||||
# uses: android-actions/setup-android@v2
|
# uses: android-actions/setup-android@v2
|
||||||
# - name: AVD cache
|
# - name: AVD cache
|
||||||
# uses: actions/cache@v3
|
# uses: actions/cache@v3
|
||||||
# id: avd-cache
|
# id: avd-cache
|
||||||
# with:
|
# with:
|
||||||
# path: |
|
# path: |
|
||||||
# ~/.android/avd/*
|
# ~/.android/avd/*
|
||||||
# ~/.android/adb*
|
# ~/.android/adb*
|
||||||
# key: avd-29
|
# key: avd-29
|
||||||
# - name: create AVD and generate snapshot for caching
|
# - name: create AVD and generate snapshot for caching
|
||||||
# if: steps.avd-cache.outputs.cache-hit != 'true'
|
# if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||||
# uses: reactivecircus/android-emulator-runner@v2.27.0
|
# uses: reactivecircus/android-emulator-runner@v2.27.0
|
||||||
# with:
|
# with:
|
||||||
# working-directory: ./mobile
|
# working-directory: ./mobile
|
||||||
# cores: 2
|
# cores: 2
|
||||||
# api-level: 29
|
# api-level: 29
|
||||||
# arch: x86_64
|
# arch: x86_64
|
||||||
# profile: pixel
|
# profile: pixel
|
||||||
# target: default
|
# target: default
|
||||||
# force-avd-creation: false
|
# force-avd-creation: false
|
||||||
# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||||
# disable-animations: false
|
# disable-animations: false
|
||||||
# script: echo "Generated AVD snapshot for caching."
|
# script: echo "Generated AVD snapshot for caching."
|
||||||
# - name: Setup Flutter SDK
|
# - name: Setup Flutter SDK
|
||||||
# uses: subosito/flutter-action@v2
|
# uses: subosito/flutter-action@v2
|
||||||
# with:
|
# with:
|
||||||
# channel: 'stable'
|
# channel: 'stable'
|
||||||
# flutter-version: '3.7.3'
|
# flutter-version: '3.7.3'
|
||||||
# cache: true
|
# cache: true
|
||||||
# - name: Run integration tests
|
# - name: Run integration tests
|
||||||
# uses: Wandalen/wretry.action@master
|
# uses: Wandalen/wretry.action@master
|
||||||
# with:
|
# with:
|
||||||
# action: reactivecircus/android-emulator-runner@v2.27.0
|
# action: reactivecircus/android-emulator-runner@v2.27.0
|
||||||
# with: |
|
# with: |
|
||||||
# working-directory: ./mobile
|
# working-directory: ./mobile
|
||||||
# cores: 2
|
# cores: 2
|
||||||
# api-level: 29
|
# api-level: 29
|
||||||
# arch: x86_64
|
# arch: x86_64
|
||||||
# profile: pixel
|
# profile: pixel
|
||||||
# target: default
|
# target: default
|
||||||
# force-avd-creation: false
|
# force-avd-creation: false
|
||||||
# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||||
# disable-animations: true
|
# disable-animations: true
|
||||||
# script: |
|
# script: |
|
||||||
# flutter pub get
|
# flutter pub get
|
||||||
# flutter test integration_test
|
# flutter test integration_test
|
||||||
# attempt_limit: 3
|
# attempt_limit: 3
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
|||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
|
**/.pnpm-store/**
|
||||||
|
|
||||||
docker/upload
|
docker/upload
|
||||||
docker/library
|
docker/library
|
||||||
|
|||||||
39
.pnpmfile.cjs
Normal file
39
.pnpmfile.cjs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
module.exports = {
|
||||||
|
hooks: {
|
||||||
|
readPackage: (pkg) => {
|
||||||
|
if (!pkg.name) {
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
switch (pkg.name) {
|
||||||
|
case "exiftool-vendored":
|
||||||
|
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
||||||
|
// make exiftool-vendored.pl a regular dependency
|
||||||
|
pkg.dependencies["exiftool-vendored.pl"] =
|
||||||
|
pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||||
|
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "sharp":
|
||||||
|
const optionalDeps = Object.keys(pkg.optionalDependencies).filter(
|
||||||
|
(dep) => dep.startsWith("@img")
|
||||||
|
);
|
||||||
|
for (const dep of optionalDeps) {
|
||||||
|
// remove all optionalDepdencies from sharp (they will be compiled from source), except:
|
||||||
|
// include the precompiled musl version of sharp, for web/Dockerfile
|
||||||
|
// include precompiled linux-x64 version of sharp, for server/Dockerfile, stage: web-prod
|
||||||
|
// include precompiled linux-arm64 version of sharp, for server/Dockerfile, stage: web-prod
|
||||||
|
if (
|
||||||
|
dep.includes("musl") ||
|
||||||
|
dep.includes("linux-x64") ||
|
||||||
|
dep.includes("linux-arm64")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
delete pkg.optionalDependencies[dep];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return pkg;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -56,7 +56,8 @@
|
|||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
|
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||||
|
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||||
},
|
},
|
||||||
"svelte.enable-ts-plugin": true,
|
"svelte.enable-ts-plugin": true,
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||||
|
|||||||
74
Makefile
74
Makefile
@@ -8,7 +8,7 @@ dev-update:
|
|||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
dev-scale:
|
dev-scale:
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||||
|
|
||||||
dev-docs:
|
dev-docs:
|
||||||
npm --prefix docs run start
|
npm --prefix docs run start
|
||||||
@@ -43,7 +43,7 @@ open-api-typescript:
|
|||||||
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
||||||
|
|
||||||
sql:
|
sql:
|
||||||
npm --prefix server run sync:sql
|
pnpm --filter immich run sync:sql
|
||||||
|
|
||||||
attach-server:
|
attach-server:
|
||||||
docker exec -it docker_immich-server_1 sh
|
docker exec -it docker_immich-server_1 sh
|
||||||
@@ -53,31 +53,40 @@ renovate:
|
|||||||
|
|
||||||
MODULES = e2e server web cli sdk docs .github
|
MODULES = e2e server web cli sdk docs .github
|
||||||
|
|
||||||
|
# directory to package name mapping function
|
||||||
|
# cli = @immich/cli
|
||||||
|
# docs = documentation
|
||||||
|
# e2e = immich-e2e
|
||||||
|
# open-api/typescript-sdk = @immich/sdk
|
||||||
|
# server = immich
|
||||||
|
# web = immich-web
|
||||||
|
map-package = $(subst sdk,@immich/sdk,$(subst cli,@immich/cli,$(subst docs,documentation,$(subst e2e,immich-e2e,$(subst server,immich,$(subst web,immich-web,$1))))))
|
||||||
|
|
||||||
audit-%:
|
audit-%:
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
pnpm --filter $(call map-package,$*) audit fix
|
||||||
install-%:
|
install-%:
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
|
pnpm --filter $(call map-package,$*) install $(if $(FROZEN),--frozen-lockfile) $(if $(OFFLINE),--offline)
|
||||||
ci-%:
|
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
|
|
||||||
build-cli: build-sdk
|
build-cli: build-sdk
|
||||||
build-web: build-sdk
|
build-web: build-sdk
|
||||||
build-%: install-%
|
build-%: install-%
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
|
pnpm --filter $(call map-package,$*) run build
|
||||||
format-%:
|
format-%:
|
||||||
npm --prefix $* run format:fix
|
pnpm --filter $(call map-package,$*) run format:fix
|
||||||
lint-%:
|
lint-%:
|
||||||
npm --prefix $* run lint:fix
|
pnpm --filter $(call map-package,$*) run lint:fix
|
||||||
|
lint-web:
|
||||||
|
pnpm --filter $(call map-package,$*) run lint:p
|
||||||
check-%:
|
check-%:
|
||||||
npm --prefix $* run check
|
pnpm --filter $(call map-package,$*) run check
|
||||||
check-web:
|
check-web:
|
||||||
npm --prefix web run check:typescript
|
pnpm --filter immich-web run check:typescript
|
||||||
npm --prefix web run check:svelte
|
pnpm --filter immich-web run check:svelte
|
||||||
test-%:
|
test-%:
|
||||||
npm --prefix $* run test
|
pnpm --filter $(call map-package,$*) run test
|
||||||
test-e2e:
|
test-e2e:
|
||||||
docker compose -f ./e2e/docker-compose.yml build
|
docker compose -f ./e2e/docker-compose.yml build
|
||||||
npm --prefix e2e run test
|
pnpm --filter immich-e2e run test
|
||||||
npm --prefix e2e run test:web
|
pnpm --filter immich-e2e run test:web
|
||||||
test-medium:
|
test-medium:
|
||||||
docker run \
|
docker run \
|
||||||
--rm \
|
--rm \
|
||||||
@@ -87,25 +96,36 @@ test-medium:
|
|||||||
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
|
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
|
||||||
-e NODE_ENV=development \
|
-e NODE_ENV=development \
|
||||||
immich-server:latest \
|
immich-server:latest \
|
||||||
-c "npm ci && npm run test:medium -- --run"
|
-c "pnpm test:medium -- --run"
|
||||||
test-medium-dev:
|
test-medium-dev:
|
||||||
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
docker exec -it immich_server /bin/sh -c "pnpm run test:medium"
|
||||||
|
|
||||||
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
|
install-all:
|
||||||
install-all: $(foreach M,$(MODULES),install-$M) ;
|
pnpm -r --filter '!documentation' install
|
||||||
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
|
|
||||||
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
|
build-all: $(foreach M,$(filter-out e2e docs .github,$(MODULES)),build-$M) ;
|
||||||
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
|
|
||||||
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
|
check-all:
|
||||||
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
pnpm -r --filter '!documentation' run "/^(check|check\:svelte|check\:typescript)$/"
|
||||||
hygiene-all: lint-all format-all check-all sql audit-all;
|
lint-all:
|
||||||
test-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),test-$M) ;
|
pnpm -r --filter '!documentation' run lint:fix
|
||||||
|
format-all:
|
||||||
|
pnpm -r --filter '!documentation' run format:fix
|
||||||
|
audit-all:
|
||||||
|
pnpm -r --filter '!documentation' audit fix
|
||||||
|
hygiene-all: audit-all
|
||||||
|
pnpm -r --filter '!documentation' run "/(format:fix|check|check:svelte|check:typescript|sql)/"
|
||||||
|
|
||||||
|
test-all:
|
||||||
|
pnpm -r --filter '!documentation' run "/^test/"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
find . -name "node_modules" -type d -prune -exec rm -rf {} +
|
find . -name "node_modules" -type d -prune -exec rm -rf {} +
|
||||||
find . -name "dist" -type d -prune -exec rm -rf '{}' +
|
find . -name "dist" -type d -prune -exec rm -rf '{}' +
|
||||||
find . -name "build" -type d -prune -exec rm -rf '{}' +
|
find . -name "build" -type d -prune -exec rm -rf '{}' +
|
||||||
find . -name "svelte-kit" -type d -prune -exec rm -rf '{}' +
|
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
|
||||||
|
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
|
||||||
|
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
|
||||||
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
|
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
|
||||||
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
|
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
|
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
|
||||||
|
|
||||||
WORKDIR /usr/src/open-api/typescript-sdk
|
|
||||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY open-api/typescript-sdk/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
COPY package* pnpm* .pnpmfile.cjs ./
|
||||||
COPY cli/package.json cli/package-lock.json ./
|
COPY ./cli ./cli/
|
||||||
RUN npm ci
|
COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/
|
||||||
|
RUN corepack enable pnpm && \
|
||||||
COPY cli .
|
pnpm install --filter @immich/sdk --filter @immich/cli --frozen-lockfile && \
|
||||||
RUN npm run build
|
pnpm --filter @immich/sdk build && \
|
||||||
|
pnpm --filter @immich/cli build
|
||||||
|
|
||||||
WORKDIR /import
|
WORKDIR /import
|
||||||
|
|
||||||
ENTRYPOINT ["node", "/usr/src/app/dist"]
|
ENTRYPOINT ["node", "/usr/src/app/cli/dist"]
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma
|
|||||||
|
|
||||||
Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder:
|
Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder:
|
||||||
|
|
||||||
$ npm install
|
# if you don't have pnpm installed
|
||||||
$ npm run build
|
$ npm install -g pnpm
|
||||||
|
$ pnpm install
|
||||||
|
$ pnpm build
|
||||||
|
|
||||||
Then, to build the open-api client run the following in the open-api folder:
|
Then, to build the open-api client run the following in the open-api folder:
|
||||||
|
|
||||||
@@ -15,8 +17,10 @@ Then, to build the open-api client run the following in the open-api folder:
|
|||||||
|
|
||||||
To run the Immich CLI from source, run the following in the cli folder:
|
To run the Immich CLI from source, run the following in the cli folder:
|
||||||
|
|
||||||
$ npm install
|
# if you don't have pnpm installed
|
||||||
$ npm run build
|
$ npm install -g pnpm
|
||||||
|
$ pnpm install
|
||||||
|
$ pnpm build
|
||||||
$ ts-node .
|
$ ts-node .
|
||||||
|
|
||||||
You'll need ts-node, the easiest way to install it is to use npm:
|
You'll need ts-node, the easiest way to install it is to use npm:
|
||||||
|
|||||||
4600
cli/package-lock.json
generated
4600
cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.77",
|
"version": "2.2.79",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
|
|||||||
@@ -21,17 +21,16 @@ services:
|
|||||||
# extends:
|
# extends:
|
||||||
# file: hwaccel.transcoding.yml
|
# file: hwaccel.transcoding.yml
|
||||||
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
target: dev
|
target: dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ../server:/usr/src/app/server
|
- ..:/usr/src/app
|
||||||
- ../open-api:/usr/src/app/open-api
|
|
||||||
- ${UPLOAD_LOCATION}/photos:/data
|
- ${UPLOAD_LOCATION}/photos:/data
|
||||||
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
||||||
- /usr/src/app/server/node_modules
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -58,8 +57,12 @@ services:
|
|||||||
- 9231:9231
|
- 9231:9231
|
||||||
- 2283:2283
|
- 2283:2283
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
redis:
|
||||||
- database
|
condition: service_started
|
||||||
|
database:
|
||||||
|
condition: service_started
|
||||||
|
init:
|
||||||
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: false
|
disable: false
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ services:
|
|||||||
image: immich-web-dev:latest
|
image: immich-web-dev:latest
|
||||||
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
|
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
|
||||||
# user: 0:0
|
# user: 0:0
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: web/Dockerfile
|
dockerfile: web/Dockerfile
|
||||||
@@ -78,18 +82,17 @@ services:
|
|||||||
- 3000:3000
|
- 3000:3000
|
||||||
- 24678:24678
|
- 24678:24678
|
||||||
volumes:
|
volumes:
|
||||||
- ../web:/usr/src/app/web
|
- ..:/usr/src/app
|
||||||
- ../i18n:/usr/src/app/i18n
|
|
||||||
- ../open-api/:/usr/src/app/open-api/
|
|
||||||
# - ../../ui:/usr/ui
|
|
||||||
- /usr/src/app/web/node_modules
|
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 1048576
|
soft: 1048576
|
||||||
hard: 1048576
|
hard: 1048576
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- immich-server
|
immich-server:
|
||||||
|
condition: service_started
|
||||||
|
init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
container_name: immich_machine_learning
|
container_name: immich_machine_learning
|
||||||
@@ -117,7 +120,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
|
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
@@ -157,6 +160,14 @@ services:
|
|||||||
# volumes:
|
# volumes:
|
||||||
# - grafana-data:/var/lib/grafana
|
# - grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
|
init:
|
||||||
|
container_name: init
|
||||||
|
image: busybox
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
user: 0:0
|
||||||
|
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
|
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
|
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
build/
|
build/
|
||||||
.docusaurus/
|
.docusaurus/
|
||||||
|
|
||||||
|
# Ignore files for PNPM, NPM and YARN
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
|
|||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```
|
```
|
||||||
$ npm install
|
$ pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ When the Dev Container starts, it automatically:
|
|||||||
|
|
||||||
1. **Runs post-create script** (`container-server-post-create.sh`):
|
1. **Runs post-create script** (`container-server-post-create.sh`):
|
||||||
- Adjusts file permissions for the `node` user
|
- Adjusts file permissions for the `node` user
|
||||||
- Installs dependencies: `npm install` in all packages
|
- Installs dependencies: `pnpm install` in all packages
|
||||||
- Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
|
- Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
|
||||||
|
|
||||||
2. **Starts development servers** via VS Code tasks:
|
2. **Starts development servers** via VS Code tasks:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ If you only want to do web development connected to an existing, remote backend,
|
|||||||
|
|
||||||
1. Build the Immich SDK - `cd open-api/typescript-sdk && npm i && npm run build && cd -`
|
1. Build the Immich SDK - `cd open-api/typescript-sdk && npm i && npm run build && cd -`
|
||||||
2. Enter the web directory - `cd web/`
|
2. Enter the web directory - `cd web/`
|
||||||
3. Install web dependencies - `npm i`
|
3. Install web dependencies - `pnpm i`
|
||||||
4. Start the web development server
|
4. Start the web development server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
### Unit tests
|
### Unit tests
|
||||||
|
|
||||||
Unit are run by calling `npm run test` from the `server/` directory.
|
Unit are run by calling `npm run test` from the `server/` directory.
|
||||||
You need to run `npm install` (in `server/`) before _once_.
|
You need to run `pnpm install` (in `server/`) before _once_.
|
||||||
|
|
||||||
### End to end tests
|
### End to end tests
|
||||||
|
|
||||||
|
|||||||
@@ -27,3 +27,102 @@ docker image prune
|
|||||||
[watchtower]: https://containrrr.dev/watchtower/
|
[watchtower]: https://containrrr.dev/watchtower/
|
||||||
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
|
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
|
||||||
[releases]: https://github.com/immich-app/immich/releases
|
[releases]: https://github.com/immich-app/immich/releases
|
||||||
|
|
||||||
|
## Migrating to VectorChord
|
||||||
|
|
||||||
|
:::info
|
||||||
|
If you deploy Immich using Docker Compose, see `ghcr.io/immich-app/postgres` in the `docker-compose.yml` file and have not explicitly set the `DB_VECTOR_EXTENSION` environmental variable, your Immich database is already using VectorChord and this section does not apply to you.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::important
|
||||||
|
If you do not deploy Immich using Docker Compose and see a deprecation warning for pgvecto.rs on server startup, you should refer to the maintainers of the Immich distribution for guidance (if using a turnkey solution) or adapt the instructions for your specific setup.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Immich has migrated off of the deprecated pgvecto.rs database extension to its successor, [VectorChord](https://github.com/tensorchord/VectorChord), which comes with performance improvements in almost every aspect. This section will guide you on how to make this change in a Docker Compose setup.
|
||||||
|
|
||||||
|
Before making any changes, please [back up your database](/docs/administration/backup-and-restore). While every effort has been made to make this migration as smooth as possible, there’s always a chance that something can go wrong.
|
||||||
|
|
||||||
|
After making a backup, please modify your `docker-compose.yml` file with the following information.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
[...]
|
||||||
|
|
||||||
|
database:
|
||||||
|
container_name: immich_postgres
|
||||||
|
- image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
|
||||||
|
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
|
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||||
|
POSTGRES_INITDB_ARGS: '--data-checksums'
|
||||||
|
+ # Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
|
||||||
|
+ # DB_STORAGE_TYPE: 'HDD'
|
||||||
|
volumes:
|
||||||
|
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
||||||
|
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||||
|
- healthcheck:
|
||||||
|
- test: >-
|
||||||
|
- pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
|
||||||
|
- Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
|
||||||
|
- --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
|
||||||
|
- echo "checksum failure count is $$Chksum";
|
||||||
|
- [ "$$Chksum" = '0' ] || exit 1
|
||||||
|
- interval: 5m
|
||||||
|
- start_interval: 30s
|
||||||
|
- start_period: 5m
|
||||||
|
- command: >-
|
||||||
|
- postgres
|
||||||
|
- -c shared_preload_libraries=vectors.so
|
||||||
|
- -c 'search_path="$$user", public, vectors'
|
||||||
|
- -c logging_collector=on
|
||||||
|
- -c max_wal_size=2GB
|
||||||
|
- -c shared_buffers=512MB
|
||||||
|
- -c wal_compression=on
|
||||||
|
+ shm_size: 128mb
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
[...]
|
||||||
|
```
|
||||||
|
|
||||||
|
:::important
|
||||||
|
If you deviated from the defaults of pg14 or pgvectors0.2.0, you must adjust the pg major version and pgvecto.rs version. If you are still using the default `docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0` image, you can just follow the changes above. For example, if the previous image is `docker.io/tensorchord/pgvecto-rs:pg16-v0.3.0`, the new image should be `ghcr.io/immich-app/postgres:16-vectorchord0.3.0-pgvectors0.3.0` instead of the image specified in the diff.
|
||||||
|
:::
|
||||||
|
|
||||||
|
After making these changes, you can start Immich as normal. Immich will make some changes to the DB during startup, which can take seconds to minutes to finish, depending on hardware and library size. In particular, it’s normal for the server logs to be seemingly stuck at `Reindexing clip_index` and `Reindexing face_index`for some time if you have over 100k assets in Immich and/or Immich is on a relatively weak server. If you see these logs and there are no errors, just give it time.
|
||||||
|
|
||||||
|
:::danger
|
||||||
|
After switching to VectorChord, you should not downgrade Immich below 1.133.0.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Please don’t hesitate to contact us on [GitHub](https://github.com/immich-app/immich/discussions) or [Discord](https://discord.immich.app/) if you encounter migration issues.
|
||||||
|
|
||||||
|
### VectorChord FAQ
|
||||||
|
|
||||||
|
#### I have a separate PostgreSQL instance shared with multiple services. How can I switch to VectorChord?
|
||||||
|
|
||||||
|
Please see the [standalone PostgreSQL documentation](/docs/administration/postgres-standalone#migrating-to-vectorchord) for migration instructions. The migration path will be different depending on whether you’re currently using pgvecto.rs or pgvector, as well as whether Immich has superuser DB permissions.
|
||||||
|
|
||||||
|
#### Why are so many lines removed from the `docker-compose.yml` file? Does this mean the health check is removed?
|
||||||
|
|
||||||
|
These lines are now incorporated into the image itself along with some additional tuning.
|
||||||
|
|
||||||
|
#### What does this change mean for my existing DB backups?
|
||||||
|
|
||||||
|
The new DB image includes pgvector and pgvecto.rs in addition to VectorChord, so you can use this image to restore from existing backups that used either of these extensions. However, backups made after switching to VectorChord require an image containing VectorChord to restore successfully.
|
||||||
|
|
||||||
|
#### Do I still need pgvecto.rs installed after migrating to VectorChord?
|
||||||
|
|
||||||
|
pgvecto.rs only needs to be available during the migration, or if you need to restore from a backup that used pgvecto.rs. For a leaner DB and a smaller image, you can optionally switch to an image variant that doesn’t have pgvecto.rs installed after you’ve performed the migration and started Immich: `ghcr.io/immich-app/postgres:14-vectorchord0.4.3`, changing the PostgreSQL version as appropriate.
|
||||||
|
|
||||||
|
#### Why does it matter whether my database is on an SSD or an HDD?
|
||||||
|
|
||||||
|
These storage mediums have different performance characteristics. As a result, the optimal settings for an SSD are not the same as those for an HDD. Either configuration is compatible with SSD and HDD, but using the right configuration will make Immich snappier. As a general tip, we recommend users store the database on an SSD whenever possible.
|
||||||
|
|
||||||
|
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
|
||||||
|
|
||||||
|
It’s a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
|
||||||
|
|
||||||
|
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
|
||||||
|
|
||||||
|
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.
|
||||||
|
|||||||
20545
docs/package-lock.json
generated
20545
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
8
docs/static/archived-versions.json
vendored
8
docs/static/archived-versions.json
vendored
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v1.138.1",
|
||||||
|
"url": "https://v1.138.1.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v1.138.0",
|
||||||
|
"url": "https://v1.138.0.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.137.3",
|
"label": "v1.137.3",
|
||||||
"url": "https://v1.137.3.archive.immich.app"
|
"url": "https://v1.137.3.archive.immich.app"
|
||||||
|
|||||||
1
e2e/.gitignore
vendored
1
e2e/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules/
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
/dist
|
||||||
|
|||||||
7419
e2e/package-lock.json
generated
7419
e2e/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.137.3",
|
"version": "1.138.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const tempDir = tmpdir();
|
|||||||
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
||||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||||
export const immichCli = (args: string[]) =>
|
export const immichCli = (args: string[]) =>
|
||||||
executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise;
|
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||||
export const immichAdmin = (args: string[]) =>
|
export const immichAdmin = (args: string[]) =>
|
||||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
||||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||||
|
|||||||
16
i18n/en.json
16
i18n/en.json
@@ -28,6 +28,9 @@
|
|||||||
"add_to_album": "Add to album",
|
"add_to_album": "Add to album",
|
||||||
"add_to_album_bottom_sheet_added": "Added to {album}",
|
"add_to_album_bottom_sheet_added": "Added to {album}",
|
||||||
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
|
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
|
||||||
|
"add_to_album_toggle": "Toggle selection for {album}",
|
||||||
|
"add_to_albums": "Add to albums",
|
||||||
|
"add_to_albums_count": "Add to albums ({count})",
|
||||||
"add_to_shared_album": "Add to shared album",
|
"add_to_shared_album": "Add to shared album",
|
||||||
"add_url": "Add URL",
|
"add_url": "Add URL",
|
||||||
"added_to_archive": "Added to archive",
|
"added_to_archive": "Added to archive",
|
||||||
@@ -497,7 +500,9 @@
|
|||||||
"assets": "Assets",
|
"assets": "Assets",
|
||||||
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
||||||
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
||||||
|
"assets_added_to_albums_count": "Added {assetTotal} assets to {albumTotal} albums",
|
||||||
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
||||||
|
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
||||||
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
||||||
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
|
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
|
||||||
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
|
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
|
||||||
@@ -514,6 +519,7 @@
|
|||||||
"assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
|
"assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
|
||||||
"assets_trashed_from_server": "{count} asset(s) trashed from the Immich server",
|
"assets_trashed_from_server": "{count} asset(s) trashed from the Immich server",
|
||||||
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
|
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
|
||||||
|
"assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums",
|
||||||
"authorized_devices": "Authorized Devices",
|
"authorized_devices": "Authorized Devices",
|
||||||
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
|
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
|
||||||
"automatic_endpoint_switching_title": "Automatic URL switching",
|
"automatic_endpoint_switching_title": "Automatic URL switching",
|
||||||
@@ -1056,6 +1062,7 @@
|
|||||||
"filter_people": "Filter people",
|
"filter_people": "Filter people",
|
||||||
"filter_places": "Filter places",
|
"filter_places": "Filter places",
|
||||||
"find_them_fast": "Find them fast by name with search",
|
"find_them_fast": "Find them fast by name with search",
|
||||||
|
"first": "First",
|
||||||
"fix_incorrect_match": "Fix incorrect match",
|
"fix_incorrect_match": "Fix incorrect match",
|
||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"folder_not_found": "Folder not found",
|
"folder_not_found": "Folder not found",
|
||||||
@@ -1177,6 +1184,7 @@
|
|||||||
"language_search_hint": "Search languages...",
|
"language_search_hint": "Search languages...",
|
||||||
"language_setting_description": "Select your preferred language",
|
"language_setting_description": "Select your preferred language",
|
||||||
"large_files": "Large Files",
|
"large_files": "Large Files",
|
||||||
|
"last": "Last",
|
||||||
"last_seen": "Last seen",
|
"last_seen": "Last seen",
|
||||||
"latest_version": "Latest Version",
|
"latest_version": "Latest Version",
|
||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
@@ -1195,6 +1203,7 @@
|
|||||||
"library_page_sort_title": "Album title",
|
"library_page_sort_title": "Album title",
|
||||||
"licenses": "Licenses",
|
"licenses": "Licenses",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
|
"like": "Like",
|
||||||
"like_deleted": "Like deleted",
|
"like_deleted": "Like deleted",
|
||||||
"link_motion_video": "Link motion video",
|
"link_motion_video": "Link motion video",
|
||||||
"link_to_oauth": "Link to OAuth",
|
"link_to_oauth": "Link to OAuth",
|
||||||
@@ -1457,9 +1466,9 @@
|
|||||||
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
|
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
|
||||||
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
|
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
|
||||||
"person": "Person",
|
"person": "Person",
|
||||||
"person_age_months": "{months} months old",
|
"person_age_months": "{months, plural, one {# month} other {# months}} old",
|
||||||
"person_age_year_months": "1 year, {months} months old",
|
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
|
||||||
"person_age_years": "{years} years old",
|
"person_age_years": "{years, plural, other {# years}} old",
|
||||||
"person_birthdate": "Born on {date}",
|
"person_birthdate": "Born on {date}",
|
||||||
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
||||||
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
|
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
|
||||||
@@ -1856,6 +1865,7 @@
|
|||||||
"sort_created": "Date created",
|
"sort_created": "Date created",
|
||||||
"sort_items": "Number of items",
|
"sort_items": "Number of items",
|
||||||
"sort_modified": "Date modified",
|
"sort_modified": "Date modified",
|
||||||
|
"sort_newest": "Newest photo",
|
||||||
"sort_oldest": "Oldest photo",
|
"sort_oldest": "Oldest photo",
|
||||||
"sort_people_by_similarity": "Sort people by similarity",
|
"sort_people_by_similarity": "Sort people by similarity",
|
||||||
"sort_recent": "Most recent photo",
|
"sort_recent": "Most recent photo",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ARG DEVICE=cpu
|
ARG DEVICE=cpu
|
||||||
|
|
||||||
FROM python:3.11-bookworm@sha256:ce3b954c9285a7a145cba620bae03db836ab890b6b9e0d05a3ca522ea00dfbc9 AS builder-cpu
|
FROM python:3.11-bookworm@sha256:c642d5dfaf9115a12086785f23008558ae2e13bcd0c4794536340bcb777a4381 AS builder-cpu
|
||||||
|
|
||||||
FROM builder-cpu AS builder-openvino
|
FROM builder-cpu AS builder-openvino
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends g++
|
RUN apt-get update && apt-get install -y --no-install-recommends g++
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:9653efd4380d5a0e5511e337dcfc3b8ba5bc4e6ea7fa3be7716598261d5503fa /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:f64ad69940b634e75d2e4d799eb5238066c5eeda49f76e782d4873c3d014ea33 /uv /uvx /bin/
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
@@ -68,11 +68,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
|
|||||||
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-cpu
|
FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-cpu
|
||||||
|
|
||||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
|
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-openvino
|
FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-openvino
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def to_numpy(img: Image.Image) -> NDArray[np.float32]:
|
|||||||
def normalize(
|
def normalize(
|
||||||
img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32]
|
img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32]
|
||||||
) -> NDArray[np.float32]:
|
) -> NDArray[np.float32]:
|
||||||
return np.divide(img - mean, std, dtype=np.float32)
|
return (img - mean) / std
|
||||||
|
|
||||||
|
|
||||||
def get_pil_resampling(resample: str) -> Image.Resampling:
|
def get_pil_resampling(resample: str) -> Image.Resampling:
|
||||||
@@ -58,11 +58,13 @@ def decode_pil(image_bytes: bytes | IO[bytes] | Image.Image) -> Image.Image:
|
|||||||
|
|
||||||
|
|
||||||
def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]:
|
def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]:
|
||||||
if isinstance(image_bytes, bytes):
|
match image_bytes:
|
||||||
image_bytes = decode_pil(image_bytes) # pillow is much faster than cv2
|
case bytes() | memoryview() | bytearray():
|
||||||
if isinstance(image_bytes, Image.Image):
|
return pil_to_cv2(decode_pil(image_bytes)) # pillow is much faster than cv2
|
||||||
return pil_to_cv2(image_bytes)
|
case Image.Image():
|
||||||
return image_bytes
|
return pil_to_cv2(image_bytes)
|
||||||
|
case _:
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
|
|
||||||
def clean_text(text: str, canonicalize: bool = False) -> str:
|
def clean_text(text: str, canonicalize: bool = False) -> str:
|
||||||
|
|||||||
@@ -112,8 +112,4 @@ def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
|
|||||||
return hasattr(obj, "profiling") and isinstance(obj.profiling, dict)
|
return hasattr(obj, "profiling") and isinstance(obj.profiling, dict)
|
||||||
|
|
||||||
|
|
||||||
def is_ndarray(obj: Any, dtype: "type[np._DTypeScalar_co]") -> "TypeGuard[npt.NDArray[np._DTypeScalar_co]]":
|
|
||||||
return isinstance(obj, np.ndarray) and obj.dtype == dtype
|
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"gunicorn>=21.1.0",
|
"gunicorn>=21.1.0",
|
||||||
"huggingface-hub>=0.20.1,<1.0",
|
"huggingface-hub>=0.20.1,<1.0",
|
||||||
"insightface>=0.7.3,<1.0",
|
"insightface>=0.7.3,<1.0",
|
||||||
|
"numpy<2",
|
||||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||||
"orjson>=3.9.5",
|
"orjson>=3.9.5",
|
||||||
"pillow>=9.5.0,<11.0",
|
"pillow>=9.5.0,<11.0",
|
||||||
|
|||||||
773
machine-learning/uv.lock
generated
773
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 3002,
|
"android.injected.version.code" => 3004,
|
||||||
"android.injected.version.name" => "1.137.3",
|
"android.injected.version.name" => "1.138.1",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ platform :ios do
|
|||||||
path: "./Runner.xcodeproj",
|
path: "./Runner.xcodeproj",
|
||||||
)
|
)
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.137.3"
|
version_number: "1.138.1"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class RemoteAlbum {
|
|||||||
final AlbumAssetOrder order;
|
final AlbumAssetOrder order;
|
||||||
final int assetCount;
|
final int assetCount;
|
||||||
final String ownerName;
|
final String ownerName;
|
||||||
|
final bool isShared;
|
||||||
|
|
||||||
const RemoteAlbum({
|
const RemoteAlbum({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -36,6 +37,7 @@ class RemoteAlbum {
|
|||||||
required this.order,
|
required this.order,
|
||||||
required this.assetCount,
|
required this.assetCount,
|
||||||
required this.ownerName,
|
required this.ownerName,
|
||||||
|
required this.isShared,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -52,6 +54,7 @@ class RemoteAlbum {
|
|||||||
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
|
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
|
||||||
assetCount: $assetCount
|
assetCount: $assetCount
|
||||||
ownerName: $ownerName
|
ownerName: $ownerName
|
||||||
|
isShared: $isShared
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +72,8 @@ class RemoteAlbum {
|
|||||||
isActivityEnabled == other.isActivityEnabled &&
|
isActivityEnabled == other.isActivityEnabled &&
|
||||||
order == other.order &&
|
order == other.order &&
|
||||||
assetCount == other.assetCount &&
|
assetCount == other.assetCount &&
|
||||||
ownerName == other.ownerName;
|
ownerName == other.ownerName &&
|
||||||
|
isShared == other.isShared;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -84,7 +88,8 @@ class RemoteAlbum {
|
|||||||
isActivityEnabled.hashCode ^
|
isActivityEnabled.hashCode ^
|
||||||
order.hashCode ^
|
order.hashCode ^
|
||||||
assetCount.hashCode ^
|
assetCount.hashCode ^
|
||||||
ownerName.hashCode;
|
ownerName.hashCode ^
|
||||||
|
isShared.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
RemoteAlbum copyWith({
|
RemoteAlbum copyWith({
|
||||||
@@ -99,6 +104,7 @@ class RemoteAlbum {
|
|||||||
AlbumAssetOrder? order,
|
AlbumAssetOrder? order,
|
||||||
int? assetCount,
|
int? assetCount,
|
||||||
String? ownerName,
|
String? ownerName,
|
||||||
|
bool? isShared,
|
||||||
}) {
|
}) {
|
||||||
return RemoteAlbum(
|
return RemoteAlbum(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -112,6 +118,7 @@ class RemoteAlbum {
|
|||||||
order: order ?? this.order,
|
order: order ?? this.order,
|
||||||
assetCount: assetCount ?? this.assetCount,
|
assetCount: assetCount ?? this.assetCount,
|
||||||
ownerName: ownerName ?? this.ownerName,
|
ownerName: ownerName ?? this.ownerName,
|
||||||
|
isShared: isShared ?? this.isShared,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
import 'package:immich_mobile/utils/remote_album.utils.dart';
|
|
||||||
|
|
||||||
class RemoteAlbumService {
|
class RemoteAlbumService {
|
||||||
final DriftRemoteAlbumRepository _repository;
|
final DriftRemoteAlbumRepository _repository;
|
||||||
@@ -26,8 +26,21 @@ class RemoteAlbumService {
|
|||||||
return _repository.get(albumId);
|
return _repository.get(albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<RemoteAlbum> sortAlbums(List<RemoteAlbum> albums, RemoteAlbumSortMode sortMode, {bool isReverse = false}) {
|
Future<List<RemoteAlbum>> sortAlbums(
|
||||||
return sortMode.sortFn(albums, isReverse);
|
List<RemoteAlbum> albums,
|
||||||
|
RemoteAlbumSortMode sortMode, {
|
||||||
|
bool isReverse = false,
|
||||||
|
}) async {
|
||||||
|
final List<RemoteAlbum> sorted = switch (sortMode) {
|
||||||
|
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
||||||
|
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||||
|
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||||
|
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||||
|
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||||
|
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (isReverse ? sorted.reversed : sorted).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<RemoteAlbum> searchAlbums(
|
List<RemoteAlbum> searchAlbums(
|
||||||
@@ -143,4 +156,60 @@ class RemoteAlbumService {
|
|||||||
Future<int> getCount() {
|
Future<int> getCount() {
|
||||||
return _repository.getCount();
|
return _repository.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
|
||||||
|
// map album IDs to their newest asset dates
|
||||||
|
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
|
||||||
|
for (final album in albums) {
|
||||||
|
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// await all database queries
|
||||||
|
final entries = await Future.wait(
|
||||||
|
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||||
|
);
|
||||||
|
final assetTimestamps = Map.fromEntries(entries);
|
||||||
|
|
||||||
|
final sorted = albums.sorted((a, b) {
|
||||||
|
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
return aDate.compareTo(bDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
|
||||||
|
// map album IDs to their oldest asset dates
|
||||||
|
final Map<String, Future<DateTime?>> assetTimestampFutures = {
|
||||||
|
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
// await all database queries
|
||||||
|
final entries = await Future.wait(
|
||||||
|
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||||
|
);
|
||||||
|
final assetTimestamps = Map.fromEntries(entries);
|
||||||
|
|
||||||
|
final sorted = albums.sorted((a, b) {
|
||||||
|
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
return aDate.compareTo(bDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted.reversed.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RemoteAlbumSortMode {
|
||||||
|
title("library_page_sort_title"),
|
||||||
|
assetCount("library_page_sort_asset_count"),
|
||||||
|
lastModified("library_page_sort_last_modified"),
|
||||||
|
created("library_page_sort_created"),
|
||||||
|
mostRecent("sort_newest"),
|
||||||
|
mostOldest("sort_oldest");
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
const RemoteAlbumSortMode(this.key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,36 @@ class TimelineService {
|
|||||||
return _buffer.elementAt(index - _bufferOffset);
|
return _buffer.elementAt(index - _bufferOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets an asset at the given index, automatically loading the buffer if needed.
|
||||||
|
/// This is an async version that can handle out-of-range indices by loading the appropriate buffer.
|
||||||
|
Future<BaseAsset?> getAssetAsync(int index) async {
|
||||||
|
if (index < 0 || index >= _totalAssets) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRange(index, 1)) {
|
||||||
|
return _buffer.elementAt(index - _bufferOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the buffer containing the requested index
|
||||||
|
try {
|
||||||
|
final assets = await loadAssets(index, 1);
|
||||||
|
return assets.isNotEmpty ? assets.first : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safely gets an asset at the given index without throwing a RangeError.
|
||||||
|
/// Returns null if the index is out of bounds or not currently in the buffer.
|
||||||
|
/// For automatic buffer loading, use getAssetAsync instead.
|
||||||
|
BaseAsset? getAssetSafe(int index) {
|
||||||
|
if (index < 0 || index >= _totalAssets || !hasRange(index, 1)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _buffer.elementAt(index - _bufferOffset);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _bucketSubscription?.cancel();
|
await _bucketSubscription?.cancel();
|
||||||
_bucketSubscription = null;
|
_bucketSubscription = null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
|||||||
import 'package:immich_mobile/utils/database.utils.dart';
|
import 'package:immich_mobile/utils/database.utils.dart';
|
||||||
import 'package:platform/platform.dart';
|
import 'package:platform/platform.dart';
|
||||||
|
|
||||||
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount }
|
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
|
||||||
|
|
||||||
class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
@@ -40,6 +40,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
SortLocalAlbumsBy.isIosSharedAlbum => OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum),
|
SortLocalAlbumsBy.isIosSharedAlbum => OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum),
|
||||||
SortLocalAlbumsBy.name => OrderingTerm.asc(_db.localAlbumEntity.name),
|
SortLocalAlbumsBy.name => OrderingTerm.asc(_db.localAlbumEntity.name),
|
||||||
SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount),
|
SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount),
|
||||||
|
SortLocalAlbumsBy.newestAsset => OrderingTerm.desc(_db.localAlbumEntity.updatedAt),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
query.orderBy(orderings);
|
query.orderBy(orderings);
|
||||||
@@ -319,7 +320,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||||
])
|
])
|
||||||
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
|
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
|
||||||
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)])
|
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
|
||||||
..limit(1);
|
..limit(1);
|
||||||
|
|
||||||
final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
|
final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
|
||||||
|
|||||||
@@ -31,11 +31,17 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
useColumns: false,
|
useColumns: false,
|
||||||
),
|
),
|
||||||
leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false),
|
leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false),
|
||||||
|
leftOuterJoin(
|
||||||
|
_db.remoteAlbumUserEntity,
|
||||||
|
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||||
|
useColumns: false,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
query
|
query
|
||||||
..where(_db.remoteAssetEntity.deletedAt.isNull())
|
..where(_db.remoteAssetEntity.deletedAt.isNull())
|
||||||
..addColumns([assetCount])
|
..addColumns([assetCount])
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
|
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
if (sortBy.isNotEmpty) {
|
if (sortBy.isNotEmpty) {
|
||||||
@@ -53,7 +59,11 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
.map(
|
.map(
|
||||||
(row) => row
|
(row) => row
|
||||||
.readTable(_db.remoteAlbumEntity)
|
.readTable(_db.remoteAlbumEntity)
|
||||||
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
|
.toDto(
|
||||||
|
assetCount: row.read(assetCount) ?? 0,
|
||||||
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
@@ -78,17 +88,27 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
|
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
|
||||||
useColumns: false,
|
useColumns: false,
|
||||||
),
|
),
|
||||||
|
leftOuterJoin(
|
||||||
|
_db.remoteAlbumUserEntity,
|
||||||
|
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||||
|
useColumns: false,
|
||||||
|
),
|
||||||
])
|
])
|
||||||
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
|
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
|
||||||
..addColumns([assetCount])
|
..addColumns([assetCount])
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
|
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
return query
|
return query
|
||||||
.map(
|
.map(
|
||||||
(row) => row
|
(row) => row
|
||||||
.readTable(_db.remoteAlbumEntity)
|
.readTable(_db.remoteAlbumEntity)
|
||||||
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
|
.toDto(
|
||||||
|
assetCount: row.read(assetCount) ?? 0,
|
||||||
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
}
|
}
|
||||||
@@ -254,24 +274,57 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
|
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
|
||||||
useColumns: false,
|
useColumns: false,
|
||||||
),
|
),
|
||||||
|
leftOuterJoin(
|
||||||
|
_db.remoteAlbumUserEntity,
|
||||||
|
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||||
|
useColumns: false,
|
||||||
|
),
|
||||||
])
|
])
|
||||||
..where(_db.remoteAlbumEntity.id.equals(albumId))
|
..where(_db.remoteAlbumEntity.id.equals(albumId))
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
|
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
final album = row.readTable(_db.remoteAlbumEntity).toDto(ownerName: row.read(_db.userEntity.name)!);
|
final album = row
|
||||||
|
.readTable(_db.remoteAlbumEntity)
|
||||||
|
.toDto(
|
||||||
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
||||||
|
);
|
||||||
return album;
|
return album;
|
||||||
}).watchSingleOrNull();
|
}).watchSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
|
||||||
|
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||||
|
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||||
|
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
|
||||||
|
..join([
|
||||||
|
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
|
||||||
|
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||||
|
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||||
|
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
|
||||||
|
..join([
|
||||||
|
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> getCount() {
|
Future<int> getCount() {
|
||||||
return _db.managers.remoteAlbumEntity.count();
|
return _db.managers.remoteAlbumEntity.count();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on RemoteAlbumEntityData {
|
extension on RemoteAlbumEntityData {
|
||||||
RemoteAlbum toDto({int assetCount = 0, required String ownerName}) {
|
RemoteAlbum toDto({int assetCount = 0, required String ownerName, required bool isShared}) {
|
||||||
return RemoteAlbum(
|
return RemoteAlbum(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -284,6 +337,7 @@ extension on RemoteAlbumEntityData {
|
|||||||
order: order,
|
order: order,
|
||||||
assetCount: assetCount,
|
assetCount: assetCount,
|
||||||
ownerName: ownerName,
|
ownerName: ownerName,
|
||||||
|
isShared: isShared,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
||||||
filter: (row) => row.deletedAt.isNull() & row.isFavorite.equals(true) & row.ownerId.equals(userId),
|
filter: (row) =>
|
||||||
|
row.deletedAt.isNull() &
|
||||||
|
row.isFavorite.equals(true) &
|
||||||
|
row.ownerId.equals(userId) &
|
||||||
|
row.visibility.equalsValue(AssetVisibility.timeline),
|
||||||
groupBy: groupBy,
|
groupBy: groupBy,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +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/backup/backup_toggle_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -39,6 +40,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ref.read(backgroundSyncProvider).syncRemote();
|
||||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||||
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|||||||
104
mobile/lib/presentation/pages/drift_activities.page.dart
Normal file
104
mobile/lib/presentation/pages/drift_activities.page.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
|
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/models/activities/activity.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.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';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
|
||||||
|
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class DriftActivitiesPage extends HookConsumerWidget {
|
||||||
|
const DriftActivitiesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final album = ref.watch(currentRemoteAlbumProvider)!;
|
||||||
|
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
|
||||||
|
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
|
||||||
|
final listViewScrollController = useScrollController();
|
||||||
|
|
||||||
|
void scrollToBottom() {
|
||||||
|
listViewScrollController.animateTo(
|
||||||
|
listViewScrollController.position.maxScrollExtent + 80,
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onAddComment(String comment) async {
|
||||||
|
await activityNotifier.addComment(comment);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: asset == null ? Text(album.name) : null,
|
||||||
|
actions: [const LikeActivityActionButton(menuItem: true)],
|
||||||
|
actionsPadding: const EdgeInsets.only(right: 8),
|
||||||
|
),
|
||||||
|
body: activities.widgetWhen(
|
||||||
|
onData: (data) {
|
||||||
|
final liked = data.firstWhereOrNull(
|
||||||
|
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
ListView.builder(
|
||||||
|
controller: listViewScrollController,
|
||||||
|
itemCount: data.length + 1,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == data.length) {
|
||||||
|
return const SizedBox(height: 80);
|
||||||
|
}
|
||||||
|
final activity = data[index];
|
||||||
|
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(5),
|
||||||
|
child: DismissibleActivity(
|
||||||
|
activity.id,
|
||||||
|
ActivityTile(activity),
|
||||||
|
onDismiss: canDelete
|
||||||
|
? (activityId) async => await activityNotifier.removeActivity(activity.id)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.scaffoldBackgroundColor,
|
||||||
|
border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)),
|
||||||
|
),
|
||||||
|
child: DriftActivityTextField(
|
||||||
|
isEnabled: album.isActivityEnabled,
|
||||||
|
likeId: liked?.id,
|
||||||
|
onSubmit: onAddComment,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
resizeToAvoidBottomInset: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,10 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> showActivity(BuildContext context) async {
|
||||||
|
context.pushRoute(const DriftActivitiesRoute());
|
||||||
|
}
|
||||||
|
|
||||||
void showOptionSheet(BuildContext context) {
|
void showOptionSheet(BuildContext context) {
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
final isOwner = user != null ? user.id == _album.ownerId : false;
|
final isOwner = user != null ? user.id == _album.ownerId : false;
|
||||||
@@ -241,6 +245,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
onShowOptions: () => showOptionSheet(context),
|
onShowOptions: () => showOptionSheet(context),
|
||||||
onToggleAlbumOrder: () => toggleAlbumOrder(),
|
onToggleAlbumOrder: () => toggleAlbumOrder(),
|
||||||
onEditTitle: () => showEditTitleAndDescription(context),
|
onEditTitle: () => showEditTitleAndDescription(context),
|
||||||
|
onActivity: () => showActivity(context),
|
||||||
),
|
),
|
||||||
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
||||||
),
|
),
|
||||||
|
|||||||
174
mobile/lib/presentation/pages/editing/drift_crop.page.dart
Normal file
174
mobile/lib/presentation/pages/editing/drift_crop.page.dart
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:crop_image/crop_image.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
|
||||||
|
|
||||||
|
/// A widget for cropping an image.
|
||||||
|
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
||||||
|
/// users to crop an image and then navigate to the [EditImagePage] with the
|
||||||
|
/// cropped image.
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class DriftCropImagePage extends HookWidget {
|
||||||
|
final Image image;
|
||||||
|
final BaseAsset asset;
|
||||||
|
const DriftCropImagePage({super.key, required this.image, required this.asset});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cropController = useCropController();
|
||||||
|
final aspectRatio = useState<double?>(null);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
|
title: Text("crop".tr()),
|
||||||
|
leading: CloseButton(color: context.primaryColor),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
|
||||||
|
onPressed: () async {
|
||||||
|
final croppedImage = await cropController.croppedImage();
|
||||||
|
context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
|
body: SafeArea(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(top: 20),
|
||||||
|
width: constraints.maxWidth * 0.9,
|
||||||
|
height: constraints.maxHeight * 0.6,
|
||||||
|
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.scaffoldBackgroundColor,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(20),
|
||||||
|
topRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
|
||||||
|
onPressed: () {
|
||||||
|
cropController.rotateLeft();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
|
||||||
|
onPressed: () {
|
||||||
|
cropController.rotateRight();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: <Widget>[
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: null,
|
||||||
|
label: 'Free',
|
||||||
|
),
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 1.0,
|
||||||
|
label: '1:1',
|
||||||
|
),
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 16.0 / 9.0,
|
||||||
|
label: '16:9',
|
||||||
|
),
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 3.0 / 2.0,
|
||||||
|
label: '3:2',
|
||||||
|
),
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 7.0 / 5.0,
|
||||||
|
label: '7:5',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AspectRatioButton extends StatelessWidget {
|
||||||
|
final CropController cropController;
|
||||||
|
final ValueNotifier<double?> aspectRatio;
|
||||||
|
final double? ratio;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _AspectRatioButton({
|
||||||
|
required this.cropController,
|
||||||
|
required this.aspectRatio,
|
||||||
|
required this.ratio,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(switch (label) {
|
||||||
|
'Free' => Icons.crop_free_rounded,
|
||||||
|
'1:1' => Icons.crop_square_rounded,
|
||||||
|
'16:9' => Icons.crop_16_9_rounded,
|
||||||
|
'3:2' => Icons.crop_3_2_rounded,
|
||||||
|
'7:5' => Icons.crop_7_5_rounded,
|
||||||
|
_ => Icons.crop_free_rounded,
|
||||||
|
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
|
||||||
|
onPressed: () {
|
||||||
|
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
|
||||||
|
aspectRatio.value = ratio;
|
||||||
|
cropController.aspectRatio = ratio;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Text(label, style: context.textTheme.displayMedium),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
165
mobile/lib/presentation/pages/editing/drift_edit.page.dart
Normal file
165
mobile/lib/presentation/pages/editing/drift_edit.page.dart
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// A stateless widget that provides functionality for editing an image.
|
||||||
|
///
|
||||||
|
/// This widget allows users to edit an image provided either as an [Asset] or
|
||||||
|
/// directly as an [Image]. It ensures that exactly one of these is provided.
|
||||||
|
///
|
||||||
|
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
|
||||||
|
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
|
||||||
|
@immutable
|
||||||
|
@RoutePage()
|
||||||
|
class DriftEditImagePage extends ConsumerWidget {
|
||||||
|
final BaseAsset asset;
|
||||||
|
final Image image;
|
||||||
|
final bool isEdited;
|
||||||
|
|
||||||
|
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
|
||||||
|
Future<Uint8List> _imageToUint8List(Image image) async {
|
||||||
|
final Completer<Uint8List> completer = Completer();
|
||||||
|
image.image
|
||||||
|
.resolve(const ImageConfiguration())
|
||||||
|
.addListener(
|
||||||
|
ImageStreamListener((ImageInfo info, bool _) {
|
||||||
|
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
|
||||||
|
if (byteData != null) {
|
||||||
|
completer.complete(byteData.buffer.asUint8List());
|
||||||
|
} else {
|
||||||
|
completer.completeError('Failed to convert image to bytes');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, onError: (exception, stackTrace) => completer.completeError(exception)),
|
||||||
|
);
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
|
||||||
|
try {
|
||||||
|
final Uint8List imageData = await _imageToUint8List(image);
|
||||||
|
LocalAsset? localAsset;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localAsset = await ref
|
||||||
|
.read(fileMediaRepositoryProvider)
|
||||||
|
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
// OS might not return the saved image back, so we handle that gracefully
|
||||||
|
// This can happen if app does not have full library access
|
||||||
|
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(backgroundSyncProvider).syncLocal(full: true);
|
||||||
|
context.navigator.popUntil((route) => route.isFirst);
|
||||||
|
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
|
||||||
|
|
||||||
|
if (localAsset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref.read(uploadServiceProvider).manualBackup([localAsset]);
|
||||||
|
} catch (e) {
|
||||||
|
ImmichToast.show(
|
||||||
|
durationInSecond: 6,
|
||||||
|
context: context,
|
||||||
|
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text("edit".tr()),
|
||||||
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
|
||||||
|
onPressed: () => context.navigator.popUntil((route) => route.isFirst),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
|
||||||
|
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
|
body: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
|
spreadRadius: 2,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||||
|
child: Image(image: image.image, fit: BoxFit.contain),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: Container(
|
||||||
|
height: 70,
|
||||||
|
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.scaffoldBackgroundColor,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(30)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: <Widget>[
|
||||||
|
Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
|
||||||
|
onPressed: () {
|
||||||
|
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Text("crop".tr(), style: context.textTheme.displayMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
|
||||||
|
onPressed: () {
|
||||||
|
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Text("filter".tr(), style: context.textTheme.displayMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
mobile/lib/presentation/pages/editing/drift_filter.page.dart
Normal file
159
mobile/lib/presentation/pages/editing/drift_filter.page.dart
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:immich_mobile/constants/filters.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/routing/router.dart';
|
||||||
|
|
||||||
|
/// A widget for filtering an image.
|
||||||
|
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
||||||
|
/// users to add filters to an image and then navigate to the [EditImagePage] with the
|
||||||
|
/// final composition.'
|
||||||
|
@RoutePage()
|
||||||
|
class DriftFilterImagePage extends HookWidget {
|
||||||
|
final Image image;
|
||||||
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
const DriftFilterImagePage({super.key, required this.image, required this.asset});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorFilter = useState<ColorFilter>(filters[0]);
|
||||||
|
final selectedFilterIndex = useState<int>(0);
|
||||||
|
|
||||||
|
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
|
||||||
|
final completer = Completer<ui.Image>();
|
||||||
|
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
final canvas = Canvas(recorder);
|
||||||
|
|
||||||
|
final paint = Paint()..colorFilter = filter;
|
||||||
|
canvas.drawImage(inputImage, Offset.zero, paint);
|
||||||
|
|
||||||
|
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
|
||||||
|
completer.complete(image);
|
||||||
|
});
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyFilter(ColorFilter filter, int index) {
|
||||||
|
colorFilter.value = filter;
|
||||||
|
selectedFilterIndex.value = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
|
||||||
|
final completer = Completer<ui.Image>();
|
||||||
|
image.image
|
||||||
|
.resolve(ImageConfiguration.empty)
|
||||||
|
.addListener(
|
||||||
|
ImageStreamListener((ImageInfo info, bool _) {
|
||||||
|
completer.complete(info.image);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final uiImage = await completer.future;
|
||||||
|
|
||||||
|
final filteredUiImage = await createFilteredImage(uiImage, filter);
|
||||||
|
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
final pngBytes = byteData!.buffer.asUint8List();
|
||||||
|
|
||||||
|
return Image.memory(pngBytes, fit: BoxFit.contain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
|
title: Text("filter".tr()),
|
||||||
|
leading: CloseButton(color: context.primaryColor),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
|
||||||
|
onPressed: () async {
|
||||||
|
final filteredImage = await applyFilterAndConvert(colorFilter.value);
|
||||||
|
context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: context.height * 0.7,
|
||||||
|
child: Center(
|
||||||
|
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: filters.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: _FilterButton(
|
||||||
|
image: image,
|
||||||
|
label: filterNames[index],
|
||||||
|
filter: filters[index],
|
||||||
|
isSelected: selectedFilterIndex.value == index,
|
||||||
|
onTap: () => applyFilter(filters[index], index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterButton extends StatelessWidget {
|
||||||
|
final Image image;
|
||||||
|
final String label;
|
||||||
|
final ColorFilter filter;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _FilterButton({
|
||||||
|
required this.image,
|
||||||
|
required this.label,
|
||||||
|
required this.filter,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||||
|
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||||
|
child: ColorFiltered(
|
||||||
|
colorFilter: filter,
|
||||||
|
child: FittedBox(fit: BoxFit.cover, child: image),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(label, style: context.themeData.textTheme.bodyMedium),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
|
||||||
|
class EditImageActionButton extends ConsumerWidget {
|
||||||
|
const EditImageActionButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final currentAsset = ref.watch(currentAssetNotifier);
|
||||||
|
|
||||||
|
onPress() {
|
||||||
|
if (currentAsset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final image = Image(image: getFullImageProvider(currentAsset));
|
||||||
|
|
||||||
|
context.navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => DriftEditImagePage(asset: currentAsset, image: image, isEdited: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaseActionButton(
|
||||||
|
iconData: Icons.tune,
|
||||||
|
label: "edit".t(context: context),
|
||||||
|
onPressed: onPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
|
class LikeActivityActionButton extends ConsumerWidget {
|
||||||
|
const LikeActivityActionButton({super.key, this.menuItem = false});
|
||||||
|
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final album = ref.watch(currentRemoteAlbumProvider);
|
||||||
|
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||||
|
|
||||||
|
onTap(Activity? liked) async {
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liked != null) {
|
||||||
|
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).removeActivity(liked.id);
|
||||||
|
} else {
|
||||||
|
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).addLike();
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.invalidate(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities.when(
|
||||||
|
data: (data) {
|
||||||
|
final liked = data.firstWhereOrNull(
|
||||||
|
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return BaseActionButton(
|
||||||
|
maxWidth: 60,
|
||||||
|
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
||||||
|
label: "like".t(context: context),
|
||||||
|
onPressed: () => onTap(liked),
|
||||||
|
menuItem: menuItem,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// default to empty heart during loading
|
||||||
|
loading: () => BaseActionButton(
|
||||||
|
iconData: Icons.favorite_border,
|
||||||
|
label: "like".t(context: context),
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
error: (error, stack) => Text("Error: $error"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ 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/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
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';
|
||||||
@@ -18,7 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
|||||||
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';
|
||||||
import 'package:immich_mobile/utils/remote_album.utils.dart';
|
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
import 'package:sliver_tools/sliver_tools.dart';
|
||||||
@@ -137,21 +138,28 @@ class _SortButton extends ConsumerStatefulWidget {
|
|||||||
class _SortButtonState extends ConsumerState<_SortButton> {
|
class _SortButtonState extends ConsumerState<_SortButton> {
|
||||||
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
|
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
|
||||||
bool albumSortIsReverse = true;
|
bool albumSortIsReverse = true;
|
||||||
|
bool isSorting = false;
|
||||||
|
|
||||||
void onMenuTapped(RemoteAlbumSortMode sortMode) {
|
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
|
||||||
final selected = albumSortOption == sortMode;
|
final selected = albumSortOption == sortMode;
|
||||||
// Switch direction
|
// Switch direction
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setState(() {
|
setState(() {
|
||||||
albumSortIsReverse = !albumSortIsReverse;
|
albumSortIsReverse = !albumSortIsReverse;
|
||||||
|
isSorting = true;
|
||||||
});
|
});
|
||||||
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
albumSortOption = sortMode;
|
albumSortOption = sortMode;
|
||||||
|
isSorting = true;
|
||||||
});
|
});
|
||||||
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
isSorting = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -229,6 +237,16 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
|||||||
color: context.colorScheme.onSurface.withAlpha(225),
|
color: context.colorScheme.onSurface.withAlpha(225),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
isSorting
|
||||||
|
? SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: context.colorScheme.onSurface.withAlpha(225),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -423,42 +441,72 @@ class _AlbumList extends ConsumerWidget {
|
|||||||
sliver: SliverList.builder(
|
sliver: SliverList.builder(
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final album = albums[index];
|
final album = albums[index];
|
||||||
|
final albumTile = LargeLeadingTile(
|
||||||
return Padding(
|
title: Text(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
album.name,
|
||||||
child: LargeLeadingTile(
|
maxLines: 2,
|
||||||
title: Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
album.name,
|
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
|
||||||
),
|
|
||||||
onTap: () => onAlbumSelected(album),
|
|
||||||
leadingPadding: const EdgeInsets.only(right: 16),
|
|
||||||
leading: album.thumbnailAssetId != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
|
||||||
child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)),
|
|
||||||
)
|
|
||||||
: SizedBox(
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.colorScheme.surfaceContainer,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
|
||||||
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||||
|
),
|
||||||
|
onTap: () => onAlbumSelected(album),
|
||||||
|
leadingPadding: const EdgeInsets.only(right: 16),
|
||||||
|
leading: album.thumbnailAssetId != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||||
|
child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)),
|
||||||
|
)
|
||||||
|
: SizedBox(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colorScheme.surfaceContainer,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
|
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
final isOwner = album.ownerId == userId;
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Dismissible(
|
||||||
|
key: ValueKey(album.id),
|
||||||
|
background: Container(
|
||||||
|
color: context.colorScheme.error,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Icon(Icons.delete, color: context.colorScheme.onError),
|
||||||
|
),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
confirmDismiss: (direction) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ConfirmDialog(
|
||||||
|
onOk: () => true,
|
||||||
|
title: "delete_album".t(context: context),
|
||||||
|
content: "album_delete_confirmation".t(context: context, args: {'album': album.name}),
|
||||||
|
ok: "delete".t(context: context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDismissed: (direction) async {
|
||||||
|
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id);
|
||||||
|
},
|
||||||
|
child: albumTile,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Padding(padding: const EdgeInsets.only(bottom: 8.0), child: albumTile);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
itemCount: albums.length,
|
itemCount: albums.length,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||||
|
|
||||||
|
class DriftActivityTextField extends ConsumerStatefulWidget {
|
||||||
|
final bool isEnabled;
|
||||||
|
final String? likeId;
|
||||||
|
final Function(String) onSubmit;
|
||||||
|
final Function()? onKeyboardFocus;
|
||||||
|
|
||||||
|
const DriftActivityTextField({
|
||||||
|
required this.onSubmit,
|
||||||
|
this.isEnabled = true,
|
||||||
|
this.likeId,
|
||||||
|
this.onKeyboardFocus,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<DriftActivityTextField> createState() => _DriftActivityTextFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField> {
|
||||||
|
late FocusNode inputFocusNode;
|
||||||
|
late TextEditingController inputController;
|
||||||
|
bool sendEnabled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
inputController = TextEditingController();
|
||||||
|
inputFocusNode = FocusNode();
|
||||||
|
|
||||||
|
inputFocusNode.requestFocus();
|
||||||
|
|
||||||
|
inputFocusNode.addListener(() {
|
||||||
|
if (inputFocusNode.hasFocus) {
|
||||||
|
widget.onKeyboardFocus?.call();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inputController.addListener(() {
|
||||||
|
setState(() {
|
||||||
|
sendEnabled = inputController.text.trim().isNotEmpty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
inputController.dispose();
|
||||||
|
inputFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
// Pass text to callback and reset controller
|
||||||
|
void onEditingComplete() {
|
||||||
|
if (inputController.text.trim().isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onSubmit(inputController.text);
|
||||||
|
inputController.clear();
|
||||||
|
inputFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: TextField(
|
||||||
|
controller: inputController,
|
||||||
|
enabled: widget.isEnabled,
|
||||||
|
focusNode: inputFocusNode,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
autofocus: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 12), // Adjust as needed
|
||||||
|
border: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
prefixIcon: user != null
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||||
|
child: UserCircleAvatar(user: user, size: 30, radius: 15),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: sendEnabled ? onEditingComplete : null,
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
iconSize: 24,
|
||||||
|
color: context.primaryColor,
|
||||||
|
disabledColor: context.colorScheme.secondaryContainer,
|
||||||
|
),
|
||||||
|
hintText: !widget.isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
|
||||||
|
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
onEditingComplete: onEditingComplete,
|
||||||
|
onTapOutside: (_) => inputFocusNode.unfocus(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,20 +127,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
_delayedOperations.clear();
|
_delayedOperations.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is used to calculate the scale of the asset when the bottom sheet is showing.
|
|
||||||
// It is a small increment to ensure that the asset is slightly zoomed in when the
|
|
||||||
// bottom sheet is showing, which emulates the zoom effect.
|
|
||||||
double get _getScaleForBottomSheet => (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + 0.01;
|
|
||||||
|
|
||||||
double _getVerticalOffsetForBottomSheet(double extent) =>
|
double _getVerticalOffsetForBottomSheet(double extent) =>
|
||||||
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
||||||
|
|
||||||
Future<void> _precacheImage(int index) async {
|
Future<void> _precacheImage(int index) async {
|
||||||
if (!mounted || index < 0 || index >= totalAssets) {
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
|
final asset = await timelineService.getAssetAsync(index);
|
||||||
|
|
||||||
|
if (asset == null || !mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
|
||||||
final screenSize = Size(context.width, context.height);
|
final screenSize = Size(context.width, context.height);
|
||||||
|
|
||||||
// Precache both thumbnail and full image for smooth transitions
|
// Precache both thumbnail and full image for smooth transitions
|
||||||
@@ -152,8 +153,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetChanged(int index) {
|
void _onAssetChanged(int index) async {
|
||||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
// Validate index bounds and try to get asset, loading buffer if needed
|
||||||
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
|
final asset = await timelineService.getAssetAsync(index);
|
||||||
|
|
||||||
|
if (asset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
// The currentAssetNotifier actually holds the current asset that is displayed
|
// The currentAssetNotifier actually holds the current asset that is displayed
|
||||||
@@ -217,19 +225,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
final verticalOffset =
|
final verticalOffset =
|
||||||
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
||||||
controller.position = Offset(0, -verticalOffset);
|
controller.position = Offset(0, -verticalOffset);
|
||||||
|
// Apply the zoom effect when the bottom sheet is showing
|
||||||
|
initialScale = controller.scale;
|
||||||
|
controller.scale = (controller.scale ?? 1.0) + 0.01;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
|
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
|
||||||
_onAssetChanged(index);
|
_onAssetChanged(index);
|
||||||
viewController = controller;
|
viewController = controller;
|
||||||
|
|
||||||
// If the bottom sheet is showing, we need to adjust scale the asset to
|
|
||||||
// emulate the zoom effect
|
|
||||||
if (showingBottomSheet) {
|
|
||||||
initialScale = controller?.scale;
|
|
||||||
controller?.scale = _getScaleForBottomSheet;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragStart(
|
void _onDragStart(
|
||||||
@@ -412,16 +416,22 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetReloadEvent() {
|
void _onAssetReloadEvent() async {
|
||||||
setState(() {
|
final index = pageController.page?.round() ?? 0;
|
||||||
final index = pageController.page?.round() ?? 0;
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
final newAsset = ref.read(timelineServiceProvider).getAsset(index);
|
final newAsset = await timelineService.getAssetAsync(index);
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
|
||||||
// Do not reload / close the bottom sheet if the asset has not changed
|
|
||||||
if (newAsset.heroTag == currentAsset?.heroTag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (newAsset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentAsset = ref.read(currentAssetNotifier);
|
||||||
|
// Do not reload / close the bottom sheet if the asset has not changed
|
||||||
|
if (newAsset.heroTag == currentAsset?.heroTag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
_onAssetChanged(pageController.page!.round());
|
_onAssetChanged(pageController.page!.round());
|
||||||
sheetCloseController?.close();
|
sheetCloseController?.close();
|
||||||
});
|
});
|
||||||
@@ -430,7 +440,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) {
|
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) {
|
||||||
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||||
initialScale = viewController?.scale;
|
initialScale = viewController?.scale;
|
||||||
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
|
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
|
||||||
previousExtent = _kBottomSheetMinimumExtent;
|
previousExtent = _kBottomSheetMinimumExtent;
|
||||||
sheetCloseController = showBottomSheet(
|
sheetCloseController = showBottomSheet(
|
||||||
context: ctx,
|
context: ctx,
|
||||||
@@ -468,16 +478,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
|
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
|
||||||
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
|
final asset = timelineService.getAssetSafe(index);
|
||||||
|
|
||||||
|
// If asset is not available in buffer, show a loading container
|
||||||
|
if (asset == null) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
color: backgroundColor,
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseAsset displayAsset = asset;
|
||||||
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
||||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||||
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
||||||
}
|
}
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
child: Thumbnail(asset: asset, fit: BoxFit.contain),
|
child: Thumbnail(asset: displayAsset, fit: BoxFit.contain),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,18 +516,34 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||||
scaffoldContext ??= ctx;
|
scaffoldContext ??= ctx;
|
||||||
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
|
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;
|
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
||||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||||
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
||||||
}
|
}
|
||||||
|
|
||||||
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
|
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
|
||||||
if (asset.isImage && !isPlayingMotionVideo) {
|
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||||
return _imageBuilder(ctx, asset);
|
return _imageBuilder(ctx, displayAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _videoBuilder(ctx, asset);
|
return _videoBuilder(ctx, displayAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
|
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
|
||||||
@@ -515,8 +554,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
tightMode: true,
|
tightMode: true,
|
||||||
initialScale: PhotoViewComputedScale.contained * 0.999,
|
|
||||||
minScale: PhotoViewComputedScale.contained * 0.999,
|
|
||||||
disableScaleGestures: showingBottomSheet,
|
disableScaleGestures: showingBottomSheet,
|
||||||
onDragStart: _onDragStart,
|
onDragStart: _onDragStart,
|
||||||
onDragUpdate: _onDragUpdate,
|
onDragUpdate: _onDragUpdate,
|
||||||
@@ -545,9 +582,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
onTapDown: _onTapDown,
|
onTapDown: _onTapDown,
|
||||||
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
initialScale: PhotoViewComputedScale.contained * 0.99,
|
|
||||||
maxScale: 1.0,
|
maxScale: 1.0,
|
||||||
minScale: PhotoViewComputedScale.contained * 0.99,
|
|
||||||
basePosition: Alignment.center,
|
basePosition: Alignment.center,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: ctx.width,
|
width: ctx.width,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_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/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/current_asset.provider.dart';
|
||||||
@@ -30,6 +32,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
int 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));
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
|
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||||
|
|
||||||
if (!showControls) {
|
if (!showControls) {
|
||||||
opacity = 0;
|
opacity = 0;
|
||||||
@@ -38,10 +41,16 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
const ShareActionButton(source: ActionSource.viewer),
|
||||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||||
if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer),
|
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||||
asset.isLocalOnly
|
if (isOwner) ...[
|
||||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
if (asset.hasRemote && isOwner && isArchived)
|
||||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
const UnArchiveActionButton(source: ActionSource.viewer)
|
||||||
|
else
|
||||||
|
const ArchiveActionButton(source: ActionSource.viewer),
|
||||||
|
asset.isLocalOnly
|
||||||
|
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||||
|
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
|
|||||||
@@ -6,17 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/exif.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/build_context_extensions.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/archive_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.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_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/bottom_sheet/sheet_people_details.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
@@ -25,6 +14,8 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
|
|||||||
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/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
@@ -44,32 +35,25 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||||
|
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
||||||
|
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||||
|
|
||||||
final actions = <Widget>[
|
final buttonContext = ActionButtonContext(
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
asset: asset,
|
||||||
if (asset.hasRemote) ...[
|
isOwner: isOwner,
|
||||||
const ShareLinkActionButton(source: ActionSource.viewer),
|
isArchived: isArchived,
|
||||||
const ArchiveActionButton(source: ActionSource.viewer),
|
isTrashEnabled: isTrashEnable,
|
||||||
if (!asset.hasLocal) const DownloadActionButton(source: ActionSource.viewer),
|
isInLockedView: isInLockedView,
|
||||||
isTrashEnable
|
currentAlbum: currentAlbum,
|
||||||
? const TrashActionButton(source: ActionSource.viewer)
|
source: ActionSource.viewer,
|
||||||
: const DeletePermanentActionButton(source: ActionSource.viewer),
|
);
|
||||||
const DeleteActionButton(source: ActionSource.viewer),
|
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.viewer),
|
|
||||||
],
|
|
||||||
if (asset.storage == AssetState.local) ...[
|
|
||||||
const DeleteLocalActionButton(source: ActionSource.viewer),
|
|
||||||
const UploadActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
|
||||||
if (currentAlbum != null) RemoveFromAlbumActionButton(albumId: currentAlbum.id, source: ActionSource.viewer),
|
|
||||||
];
|
|
||||||
|
|
||||||
final lockedViewActions = <Widget>[];
|
final actions = ActionButtonBuilder.build(buttonContext);
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
actions: isInLockedView ? lockedViewActions : actions,
|
actions: actions,
|
||||||
slivers: const [_AssetDetailBottomSheet()],
|
slivers: const [_AssetDetailBottomSheet()],
|
||||||
controller: controller,
|
controller: controller,
|
||||||
initialChildSize: initialChildSize,
|
initialChildSize: initialChildSize,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 150,
|
height: 160,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
padding: const EdgeInsets.only(left: 16.0),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
|
|||||||
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/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/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/current_album.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';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
@@ -28,12 +28,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final album = ref.watch(currentRemoteAlbumProvider);
|
||||||
|
|
||||||
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 isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
|
|
||||||
final previousRouteName = ref.watch(previousRouteNameProvider);
|
final previousRouteName = ref.watch(previousRouteNameProvider);
|
||||||
final showViewInTimelineButton = previousRouteName != TabShellRoute.name && previousRouteName != null;
|
final showViewInTimelineButton =
|
||||||
|
previousRouteName != TabShellRoute.name &&
|
||||||
|
previousRouteName != AssetViewerRoute.name &&
|
||||||
|
previousRouteName != null;
|
||||||
|
|
||||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||||
@@ -44,10 +49,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
final websocketConnected = ref.watch(websocketProvider.select((c) => c.isConnected));
|
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
|
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||||
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chat_outlined),
|
||||||
|
onPressed: () {
|
||||||
|
context.navigateTo(const DriftActivitiesRoute());
|
||||||
|
},
|
||||||
|
),
|
||||||
if (showViewInTimelineButton)
|
if (showViewInTimelineButton)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -67,7 +78,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
];
|
];
|
||||||
|
|
||||||
final lockedViewActions = <Widget>[
|
final lockedViewActions = <Widget>[
|
||||||
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
|
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||||
const _KebabMenu(),
|
const _KebabMenu(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,8 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
|
|||||||
shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
|
shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
|
||||||
builder: (BuildContext context, ScrollController scrollController) {
|
builder: (BuildContext context, ScrollController scrollController) {
|
||||||
return Card(
|
return Card(
|
||||||
color: widget.backgroundColor ?? context.colorScheme.surface,
|
color: widget.backgroundColor ?? context.colorScheme.surfaceContainer,
|
||||||
borderOnForeground: false,
|
elevation: 3.0,
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
elevation: 6.0,
|
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 0),
|
margin: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
|
|||||||
import 'package:immich_mobile/extensions/codec_extensions.dart';
|
import 'package:immich_mobile/extensions/codec_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||||
@@ -107,7 +106,6 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
|||||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)),
|
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<String>('Id', key.id),
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
|
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
|
||||||
@@ -117,13 +115,30 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Streams in each stage of the image as we ask for it
|
// Streams in each stage of the image as we ask for it
|
||||||
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||||
try {
|
try {
|
||||||
return switch (key.type) {
|
// First, yield the thumbnail image from LocalThumbProvider
|
||||||
|
final thumbProvider = LocalThumbProvider(id: key.id, updatedAt: key.updatedAt);
|
||||||
|
try {
|
||||||
|
final thumbCodec = await thumbProvider._codec(
|
||||||
|
thumbProvider,
|
||||||
|
thumbProvider.cacheManager ?? ThumbnailImageCacheManager(),
|
||||||
|
decode,
|
||||||
|
);
|
||||||
|
final thumbImageInfo = await thumbCodec.getImageInfo();
|
||||||
|
yield thumbImageInfo;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Then proceed with the main image loading stream
|
||||||
|
final mainStream = switch (key.type) {
|
||||||
AssetType.image => _decodeProgressive(key, decode),
|
AssetType.image => _decodeProgressive(key, decode),
|
||||||
AssetType.video => _getThumbnailCodec(key, decode),
|
AssetType.video => _getThumbnailCodec(key, decode),
|
||||||
_ => throw StateError('Unsupported asset type ${key.type}'),
|
_ => throw StateError('Unsupported asset type ${key.type}'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await for (final imageInfo in mainStream) {
|
||||||
|
yield imageInfo;
|
||||||
|
}
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack);
|
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack);
|
||||||
throw const ImageLoadingException('Could not load image from local storage');
|
throw const ImageLoadingException('Could not load image from local storage');
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ final localAlbumServiceProvider = Provider<LocalAlbumService>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
final localAlbumProvider = FutureProvider<List<LocalAlbum>>(
|
final localAlbumProvider = FutureProvider<List<LocalAlbum>>(
|
||||||
(ref) => LocalAlbumService(ref.watch(localAlbumRepository)).getAll(),
|
(ref) => LocalAlbumService(ref.watch(localAlbumRepository)).getAll(sortBy: {SortLocalAlbumsBy.newestAsset}),
|
||||||
);
|
);
|
||||||
|
|
||||||
final localAlbumThumbnailProvider = FutureProvider.family<LocalAsset?, String>(
|
final localAlbumThumbnailProvider = FutureProvider.family<LocalAsset?, String>(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||||
import 'package:immich_mobile/utils/remote_album.utils.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
@@ -71,8 +70,8 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
|||||||
state = state.copyWith(filteredAlbums: state.albums);
|
state = state.copyWith(filteredAlbums: state.albums);
|
||||||
}
|
}
|
||||||
|
|
||||||
void sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) {
|
Future<void> sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) async {
|
||||||
final sortedAlbums = _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
|
final sortedAlbums = await _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
|
||||||
state = state.copyWith(filteredAlbums: sortedAlbums);
|
state = state.copyWith(filteredAlbums: sortedAlbums);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ class AlbumApiRepository extends ApiRepository {
|
|||||||
order: dto.order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
|
order: dto.order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
|
||||||
assetCount: dto.assetCount,
|
assetCount: dto.assetCount,
|
||||||
ownerName: dto.owner.name,
|
ownerName: dto.owner.name,
|
||||||
|
isShared: dto.albumUsers.length > 2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ extension on AlbumResponseDto {
|
|||||||
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
|
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
|
||||||
assetCount: assetCount,
|
assetCount: assetCount,
|
||||||
ownerName: owner.name,
|
ownerName: owner.name,
|
||||||
|
isShared: albumUsers.length > 2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType;
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' hide AssetType;
|
import 'package:photo_manager/photo_manager.dart' hide AssetType;
|
||||||
|
|
||||||
@@ -15,6 +16,18 @@ class FileMediaRepository {
|
|||||||
return AssetMediaRepository.toAsset(entity);
|
return AssetMediaRepository.toAsset(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<LocalAsset?> saveLocalAsset(Uint8List data, {required String title, String? relativePath}) async {
|
||||||
|
final entity = await PhotoManager.editor.saveImage(data, filename: title, title: title, relativePath: relativePath);
|
||||||
|
|
||||||
|
return LocalAsset(
|
||||||
|
id: entity.id,
|
||||||
|
name: title,
|
||||||
|
type: AssetType.image,
|
||||||
|
createdAt: entity.createDateTime,
|
||||||
|
updatedAt: entity.modifiedDateTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Asset?> saveImageWithFile(String filePath, {String? title, String? relativePath}) async {
|
Future<Asset?> saveImageWithFile(String filePath, {String? title, String? relativePath}) async {
|
||||||
final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath);
|
final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath);
|
||||||
return AssetMediaRepository.toAsset(entity);
|
return AssetMediaRepository.toAsset(entity);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
|||||||
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
|
||||||
@@ -101,6 +102,9 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
|
|||||||
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
|
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||||
@@ -333,6 +337,10 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
|
AutoRoute(page: DriftEditImageRoute.page),
|
||||||
|
AutoRoute(page: DriftCropImageRoute.page),
|
||||||
|
AutoRoute(page: DriftFilterImageRoute.page),
|
||||||
|
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
|||||||
@@ -667,6 +667,22 @@ class CropImageRouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DriftActivitiesPage]
|
||||||
|
class DriftActivitiesRoute extends PageRouteInfo<void> {
|
||||||
|
const DriftActivitiesRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(DriftActivitiesRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'DriftActivitiesRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const DriftActivitiesPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftAlbumOptionsPage]
|
/// [DriftAlbumOptionsPage]
|
||||||
class DriftAlbumOptionsRoute extends PageRouteInfo<void> {
|
class DriftAlbumOptionsRoute extends PageRouteInfo<void> {
|
||||||
@@ -828,6 +844,112 @@ class DriftCreateAlbumRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DriftCropImagePage]
|
||||||
|
class DriftCropImageRoute extends PageRouteInfo<DriftCropImageRouteArgs> {
|
||||||
|
DriftCropImageRoute({
|
||||||
|
Key? key,
|
||||||
|
required Image image,
|
||||||
|
required BaseAsset asset,
|
||||||
|
List<PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
DriftCropImageRoute.name,
|
||||||
|
args: DriftCropImageRouteArgs(key: key, image: image, asset: asset),
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'DriftCropImageRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final args = data.argsAs<DriftCropImageRouteArgs>();
|
||||||
|
return DriftCropImagePage(
|
||||||
|
key: args.key,
|
||||||
|
image: args.image,
|
||||||
|
asset: args.asset,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DriftCropImageRouteArgs {
|
||||||
|
const DriftCropImageRouteArgs({
|
||||||
|
this.key,
|
||||||
|
required this.image,
|
||||||
|
required this.asset,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final Image image;
|
||||||
|
|
||||||
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DriftCropImageRouteArgs{key: $key, image: $image, asset: $asset}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DriftEditImagePage]
|
||||||
|
class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
|
||||||
|
DriftEditImageRoute({
|
||||||
|
Key? key,
|
||||||
|
required BaseAsset asset,
|
||||||
|
required Image image,
|
||||||
|
required bool isEdited,
|
||||||
|
List<PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
DriftEditImageRoute.name,
|
||||||
|
args: DriftEditImageRouteArgs(
|
||||||
|
key: key,
|
||||||
|
asset: asset,
|
||||||
|
image: image,
|
||||||
|
isEdited: isEdited,
|
||||||
|
),
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'DriftEditImageRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final args = data.argsAs<DriftEditImageRouteArgs>();
|
||||||
|
return DriftEditImagePage(
|
||||||
|
key: args.key,
|
||||||
|
asset: args.asset,
|
||||||
|
image: args.image,
|
||||||
|
isEdited: args.isEdited,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DriftEditImageRouteArgs {
|
||||||
|
const DriftEditImageRouteArgs({
|
||||||
|
this.key,
|
||||||
|
required this.asset,
|
||||||
|
required this.image,
|
||||||
|
required this.isEdited,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
final Image image;
|
||||||
|
|
||||||
|
final bool isEdited;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftFavoritePage]
|
/// [DriftFavoritePage]
|
||||||
class DriftFavoriteRoute extends PageRouteInfo<void> {
|
class DriftFavoriteRoute extends PageRouteInfo<void> {
|
||||||
@@ -844,6 +966,54 @@ class DriftFavoriteRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DriftFilterImagePage]
|
||||||
|
class DriftFilterImageRoute extends PageRouteInfo<DriftFilterImageRouteArgs> {
|
||||||
|
DriftFilterImageRoute({
|
||||||
|
Key? key,
|
||||||
|
required Image image,
|
||||||
|
required BaseAsset asset,
|
||||||
|
List<PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
DriftFilterImageRoute.name,
|
||||||
|
args: DriftFilterImageRouteArgs(key: key, image: image, asset: asset),
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'DriftFilterImageRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final args = data.argsAs<DriftFilterImageRouteArgs>();
|
||||||
|
return DriftFilterImagePage(
|
||||||
|
key: args.key,
|
||||||
|
image: args.image,
|
||||||
|
asset: args.asset,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DriftFilterImageRouteArgs {
|
||||||
|
const DriftFilterImageRouteArgs({
|
||||||
|
this.key,
|
||||||
|
required this.image,
|
||||||
|
required this.asset,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final Image image;
|
||||||
|
|
||||||
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DriftFilterImageRouteArgs{key: $key, image: $image, asset: $asset}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftLibraryPage]
|
/// [DriftLibraryPage]
|
||||||
class DriftLibraryRoute extends PageRouteInfo<void> {
|
class DriftLibraryRoute extends PageRouteInfo<void> {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:immich_mobile/constants/errors.dart';
|
||||||
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
||||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||||
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
||||||
@@ -30,7 +31,11 @@ class ActivityService with ErrorLoggerMixin {
|
|||||||
Future<bool> removeActivity(String id) async {
|
Future<bool> removeActivity(String id) async {
|
||||||
return logError(
|
return logError(
|
||||||
() async {
|
() async {
|
||||||
await _activityApiRepository.delete(id);
|
try {
|
||||||
|
await _activityApiRepository.delete(id);
|
||||||
|
} on NoResponseDtoError {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
|
|||||||
160
mobile/lib/utils/action_button.utils.dart
Normal file
160
mobile/lib/utils/action_button.utils.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_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/upload_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
||||||
|
class ActionButtonContext {
|
||||||
|
final BaseAsset asset;
|
||||||
|
final bool isOwner;
|
||||||
|
final bool isArchived;
|
||||||
|
final bool isTrashEnabled;
|
||||||
|
final bool isInLockedView;
|
||||||
|
final RemoteAlbum? currentAlbum;
|
||||||
|
final ActionSource source;
|
||||||
|
|
||||||
|
const ActionButtonContext({
|
||||||
|
required this.asset,
|
||||||
|
required this.isOwner,
|
||||||
|
required this.isArchived,
|
||||||
|
required this.isTrashEnabled,
|
||||||
|
required this.isInLockedView,
|
||||||
|
required this.currentAlbum,
|
||||||
|
required this.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActionButtonType {
|
||||||
|
share,
|
||||||
|
shareLink,
|
||||||
|
archive,
|
||||||
|
unarchive,
|
||||||
|
download,
|
||||||
|
trash,
|
||||||
|
deletePermanent,
|
||||||
|
delete,
|
||||||
|
moveToLockFolder,
|
||||||
|
removeFromLockFolder,
|
||||||
|
deleteLocal,
|
||||||
|
upload,
|
||||||
|
removeFromAlbum,
|
||||||
|
likeActivity;
|
||||||
|
|
||||||
|
bool shouldShow(ActionButtonContext context) {
|
||||||
|
return switch (this) {
|
||||||
|
ActionButtonType.share => true,
|
||||||
|
ActionButtonType.shareLink =>
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.hasRemote,
|
||||||
|
ActionButtonType.archive =>
|
||||||
|
context.isOwner && //
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.hasRemote && //
|
||||||
|
!context.isArchived,
|
||||||
|
ActionButtonType.unarchive =>
|
||||||
|
context.isOwner && //
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.hasRemote && //
|
||||||
|
context.isArchived,
|
||||||
|
ActionButtonType.download =>
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.hasRemote && //
|
||||||
|
!context.asset.hasLocal,
|
||||||
|
ActionButtonType.trash =>
|
||||||
|
context.isOwner && //
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.hasRemote && //
|
||||||
|
context.isTrashEnabled,
|
||||||
|
ActionButtonType.deletePermanent =>
|
||||||
|
context.isOwner && //
|
||||||
|
context.asset.hasRemote && //
|
||||||
|
!context.isTrashEnabled ||
|
||||||
|
context.isInLockedView,
|
||||||
|
ActionButtonType.delete =>
|
||||||
|
context.isOwner && //
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.hasRemote,
|
||||||
|
ActionButtonType.moveToLockFolder =>
|
||||||
|
context.isOwner && //
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.hasRemote,
|
||||||
|
ActionButtonType.removeFromLockFolder =>
|
||||||
|
context.isOwner && //
|
||||||
|
context.isInLockedView && //
|
||||||
|
context.asset.hasRemote,
|
||||||
|
ActionButtonType.deleteLocal =>
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.storage == AssetState.local,
|
||||||
|
ActionButtonType.upload =>
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.asset.storage == AssetState.local,
|
||||||
|
ActionButtonType.removeFromAlbum =>
|
||||||
|
context.isOwner && //
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.currentAlbum != null,
|
||||||
|
ActionButtonType.likeActivity =>
|
||||||
|
!context.isInLockedView &&
|
||||||
|
context.currentAlbum != null &&
|
||||||
|
context.currentAlbum!.isActivityEnabled &&
|
||||||
|
context.currentAlbum!.isShared,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildButton(ActionButtonContext context) {
|
||||||
|
return switch (this) {
|
||||||
|
ActionButtonType.share => ShareActionButton(source: context.source),
|
||||||
|
ActionButtonType.shareLink => ShareLinkActionButton(source: context.source),
|
||||||
|
ActionButtonType.archive => ArchiveActionButton(source: context.source),
|
||||||
|
ActionButtonType.unarchive => UnArchiveActionButton(source: context.source),
|
||||||
|
ActionButtonType.download => DownloadActionButton(source: context.source),
|
||||||
|
ActionButtonType.trash => TrashActionButton(source: context.source),
|
||||||
|
ActionButtonType.deletePermanent => DeletePermanentActionButton(source: context.source),
|
||||||
|
ActionButtonType.delete => DeleteActionButton(source: context.source),
|
||||||
|
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(source: context.source),
|
||||||
|
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(source: context.source),
|
||||||
|
ActionButtonType.deleteLocal => DeleteLocalActionButton(source: context.source),
|
||||||
|
ActionButtonType.upload => UploadActionButton(source: context.source),
|
||||||
|
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
|
||||||
|
albumId: context.currentAlbum!.id,
|
||||||
|
source: context.source,
|
||||||
|
),
|
||||||
|
ActionButtonType.likeActivity => const LikeActivityActionButton(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActionButtonBuilder {
|
||||||
|
static const List<ActionButtonType> _actionTypes = [
|
||||||
|
ActionButtonType.share,
|
||||||
|
ActionButtonType.shareLink,
|
||||||
|
ActionButtonType.likeActivity,
|
||||||
|
ActionButtonType.archive,
|
||||||
|
ActionButtonType.unarchive,
|
||||||
|
ActionButtonType.download,
|
||||||
|
ActionButtonType.trash,
|
||||||
|
ActionButtonType.deletePermanent,
|
||||||
|
ActionButtonType.delete,
|
||||||
|
ActionButtonType.moveToLockFolder,
|
||||||
|
ActionButtonType.removeFromLockFolder,
|
||||||
|
ActionButtonType.deleteLocal,
|
||||||
|
ActionButtonType.upload,
|
||||||
|
ActionButtonType.removeFromAlbum,
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<Widget> build(ActionButtonContext context) {
|
||||||
|
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -223,11 +223,11 @@ class _DeviceAsset {
|
|||||||
const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
|
const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> runNewSync(WidgetRef ref, {bool full = false}) async {
|
Future<List<void>> runNewSync(WidgetRef ref, {bool full = false}) async {
|
||||||
ref.read(backupProvider.notifier).cancelBackup();
|
ref.read(backupProvider.notifier).cancelBackup();
|
||||||
|
|
||||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||||
Future.wait([
|
return Future.wait([
|
||||||
backgroundManager.syncLocal(full: full).then((_) {
|
backgroundManager.syncLocal(full: full).then((_) {
|
||||||
Logger("runNewSync").fine("Hashing assets after syncLocal");
|
Logger("runNewSync").fine("Hashing assets after syncLocal");
|
||||||
backgroundManager.hashAssets();
|
backgroundManager.hashAssets();
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
|
||||||
|
|
||||||
typedef AlbumSortFn = List<RemoteAlbum> Function(List<RemoteAlbum> albums, bool isReverse);
|
|
||||||
|
|
||||||
class _RemoteAlbumSortHandlers {
|
|
||||||
const _RemoteAlbumSortHandlers._();
|
|
||||||
|
|
||||||
static const AlbumSortFn created = _sortByCreated;
|
|
||||||
static List<RemoteAlbum> _sortByCreated(List<RemoteAlbum> albums, bool isReverse) {
|
|
||||||
final sorted = albums.sortedBy((album) => album.createdAt);
|
|
||||||
return (isReverse ? sorted.reversed : sorted).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
static const AlbumSortFn title = _sortByTitle;
|
|
||||||
static List<RemoteAlbum> _sortByTitle(List<RemoteAlbum> albums, bool isReverse) {
|
|
||||||
final sorted = albums.sortedBy((album) => album.name);
|
|
||||||
return (isReverse ? sorted.reversed : sorted).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
static const AlbumSortFn lastModified = _sortByLastModified;
|
|
||||||
static List<RemoteAlbum> _sortByLastModified(List<RemoteAlbum> albums, bool isReverse) {
|
|
||||||
final sorted = albums.sortedBy((album) => album.updatedAt);
|
|
||||||
return (isReverse ? sorted.reversed : sorted).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
static const AlbumSortFn assetCount = _sortByAssetCount;
|
|
||||||
static List<RemoteAlbum> _sortByAssetCount(List<RemoteAlbum> albums, bool isReverse) {
|
|
||||||
final sorted = albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
|
|
||||||
return (isReverse ? sorted.reversed : sorted).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
static const AlbumSortFn mostRecent = _sortByMostRecent;
|
|
||||||
static List<RemoteAlbum> _sortByMostRecent(List<RemoteAlbum> albums, bool isReverse) {
|
|
||||||
final sorted = albums.sorted((a, b) {
|
|
||||||
// For most recent, we sort by updatedAt in descending order
|
|
||||||
return b.updatedAt.compareTo(a.updatedAt);
|
|
||||||
});
|
|
||||||
return (isReverse ? sorted.reversed : sorted).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
static const AlbumSortFn mostOldest = _sortByMostOldest;
|
|
||||||
static List<RemoteAlbum> _sortByMostOldest(List<RemoteAlbum> albums, bool isReverse) {
|
|
||||||
final sorted = albums.sorted((a, b) {
|
|
||||||
// For oldest, we sort by createdAt in ascending order
|
|
||||||
return a.createdAt.compareTo(b.createdAt);
|
|
||||||
});
|
|
||||||
return (isReverse ? sorted.reversed : sorted).toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RemoteAlbumSortMode {
|
|
||||||
title("library_page_sort_title", _RemoteAlbumSortHandlers.title),
|
|
||||||
assetCount("library_page_sort_asset_count", _RemoteAlbumSortHandlers.assetCount),
|
|
||||||
lastModified("library_page_sort_last_modified", _RemoteAlbumSortHandlers.lastModified),
|
|
||||||
created("library_page_sort_created", _RemoteAlbumSortHandlers.created),
|
|
||||||
mostRecent("sort_recent", _RemoteAlbumSortHandlers.mostRecent),
|
|
||||||
mostOldest("sort_oldest", _RemoteAlbumSortHandlers.mostOldest);
|
|
||||||
|
|
||||||
final String key;
|
|
||||||
final AlbumSortFn sortFn;
|
|
||||||
|
|
||||||
const RemoteAlbumSortMode(this.key, this.sortFn);
|
|
||||||
}
|
|
||||||
@@ -28,12 +28,14 @@ class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
|
|||||||
this.onShowOptions,
|
this.onShowOptions,
|
||||||
this.onToggleAlbumOrder,
|
this.onToggleAlbumOrder,
|
||||||
this.onEditTitle,
|
this.onEditTitle,
|
||||||
|
this.onActivity,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final void Function()? onShowOptions;
|
final void Function()? onShowOptions;
|
||||||
final void Function()? onToggleAlbumOrder;
|
final void Function()? onToggleAlbumOrder;
|
||||||
final void Function()? onEditTitle;
|
final void Function()? onEditTitle;
|
||||||
|
final void Function()? onActivity;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<RemoteAlbumSliverAppBar> createState() => _MesmerizingSliverAppBarState();
|
ConsumerState<RemoteAlbumSliverAppBar> createState() => _MesmerizingSliverAppBarState();
|
||||||
@@ -101,12 +103,33 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
|
|||||||
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
|
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
|
||||||
onPressed: widget.onToggleAlbumOrder,
|
onPressed: widget.onToggleAlbumOrder,
|
||||||
),
|
),
|
||||||
|
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
|
||||||
|
onPressed: widget.onActivity,
|
||||||
|
),
|
||||||
if (widget.onShowOptions != null)
|
if (widget.onShowOptions != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
||||||
onPressed: widget.onShowOptions,
|
onPressed: widget.onShowOptions,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
title: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||||
|
final scrollProgress = _calculateScrollProgress(settings);
|
||||||
|
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: scrollProgress > 0.95
|
||||||
|
? Text(
|
||||||
|
currentAlbum.name,
|
||||||
|
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
flexibleSpace: Builder(
|
flexibleSpace: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||||
@@ -122,16 +145,6 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
|
|||||||
});
|
});
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
centerTitle: true,
|
|
||||||
title: AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: scrollProgress > 0.95
|
|
||||||
? Text(
|
|
||||||
currentAlbum.name,
|
|
||||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
background: _ExpandedBackground(
|
background: _ExpandedBackground(
|
||||||
scrollProgress: scrollProgress,
|
scrollProgress: scrollProgress,
|
||||||
icon: widget.icon,
|
icon: widget.icon,
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
final isBeta = Store.isBetaTimelineEnabled;
|
final isBeta = Store.isBetaTimelineEnabled;
|
||||||
if (isBeta) {
|
if (isBeta) {
|
||||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||||
await runNewSync(ref);
|
|
||||||
context.replaceRoute(const TabShellRoute());
|
context.replaceRoute(const TabShellRoute());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
|
|
||||||
final scaleState = getScaleStateFromNewScale(scale);
|
final scaleState = getScaleStateFromNewScale(scale);
|
||||||
if (scaleState == PhotoViewScaleState.zoomedOut) {
|
if (scaleState == PhotoViewScaleState.zoomedOut) {
|
||||||
scaleStateController.scaleState = PhotoViewScaleState.originalSize;
|
scaleStateController.scaleState = PhotoViewScaleState.initial;
|
||||||
} else if (scaleState == PhotoViewScaleState.zoomedIn) {
|
} else if (scaleState == PhotoViewScaleState.zoomedIn) {
|
||||||
animateRotation(controller.rotation, 0);
|
animateRotation(controller.rotation, 0);
|
||||||
if (_shouldAllowPanRotate()) {
|
if (_shouldAllowPanRotate()) {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||||||
Size? _imageSize;
|
Size? _imageSize;
|
||||||
Object? _lastException;
|
Object? _lastException;
|
||||||
StackTrace? _lastStack;
|
StackTrace? _lastStack;
|
||||||
|
bool _didLoadSynchronously = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -130,9 +131,11 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||||||
_loadingProgress = null;
|
_loadingProgress = null;
|
||||||
_lastException = null;
|
_lastException = null;
|
||||||
_lastStack = null;
|
_lastStack = null;
|
||||||
|
|
||||||
|
_didLoadSynchronously = synchronousCall;
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronousCall ? setupCB() : setState(setupCB);
|
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleError(dynamic error, StackTrace? stackTrace) {
|
void handleError(dynamic error, StackTrace? stackTrace) {
|
||||||
|
|||||||
7
mobile/openapi/README.md
generated
7
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 1.137.3
|
- API version: 1.138.1
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
@@ -77,12 +77,14 @@ Class | Method | HTTP request | Description
|
|||||||
*APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} |
|
*APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} |
|
||||||
*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} |
|
*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} |
|
||||||
*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys |
|
*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys |
|
||||||
|
*APIKeysApi* | [**getMyApiKey**](doc//APIKeysApi.md#getmyapikey) | **GET** /api-keys/me |
|
||||||
*APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} |
|
*APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} |
|
||||||
*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities |
|
*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities |
|
||||||
*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} |
|
*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} |
|
||||||
*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities |
|
*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities |
|
||||||
*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics |
|
*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics |
|
||||||
*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets |
|
*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets |
|
||||||
|
*AlbumsApi* | [**addAssetsToAlbums**](doc//AlbumsApi.md#addassetstoalbums) | **PUT** /albums/assets |
|
||||||
*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users |
|
*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users |
|
||||||
*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
|
*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
|
||||||
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
|
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
|
||||||
@@ -299,6 +301,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
|
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
|
||||||
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
|
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
|
||||||
- [AlbumUserRole](doc//AlbumUserRole.md)
|
- [AlbumUserRole](doc//AlbumUserRole.md)
|
||||||
|
- [AlbumsAddAssetsDto](doc//AlbumsAddAssetsDto.md)
|
||||||
|
- [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md)
|
||||||
- [AlbumsResponse](doc//AlbumsResponse.md)
|
- [AlbumsResponse](doc//AlbumsResponse.md)
|
||||||
- [AlbumsUpdate](doc//AlbumsUpdate.md)
|
- [AlbumsUpdate](doc//AlbumsUpdate.md)
|
||||||
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
||||||
@@ -333,6 +337,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [AudioCodec](doc//AudioCodec.md)
|
- [AudioCodec](doc//AudioCodec.md)
|
||||||
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
|
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
|
||||||
- [AvatarUpdate](doc//AvatarUpdate.md)
|
- [AvatarUpdate](doc//AvatarUpdate.md)
|
||||||
|
- [BulkIdErrorReason](doc//BulkIdErrorReason.md)
|
||||||
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
|
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
|
||||||
- [BulkIdsDto](doc//BulkIdsDto.md)
|
- [BulkIdsDto](doc//BulkIdsDto.md)
|
||||||
- [CLIPConfig](doc//CLIPConfig.md)
|
- [CLIPConfig](doc//CLIPConfig.md)
|
||||||
|
|||||||
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@@ -79,6 +79,8 @@ part 'model/album_user_add_dto.dart';
|
|||||||
part 'model/album_user_create_dto.dart';
|
part 'model/album_user_create_dto.dart';
|
||||||
part 'model/album_user_response_dto.dart';
|
part 'model/album_user_response_dto.dart';
|
||||||
part 'model/album_user_role.dart';
|
part 'model/album_user_role.dart';
|
||||||
|
part 'model/albums_add_assets_dto.dart';
|
||||||
|
part 'model/albums_add_assets_response_dto.dart';
|
||||||
part 'model/albums_response.dart';
|
part 'model/albums_response.dart';
|
||||||
part 'model/albums_update.dart';
|
part 'model/albums_update.dart';
|
||||||
part 'model/all_job_status_response_dto.dart';
|
part 'model/all_job_status_response_dto.dart';
|
||||||
@@ -113,6 +115,7 @@ part 'model/asset_visibility.dart';
|
|||||||
part 'model/audio_codec.dart';
|
part 'model/audio_codec.dart';
|
||||||
part 'model/auth_status_response_dto.dart';
|
part 'model/auth_status_response_dto.dart';
|
||||||
part 'model/avatar_update.dart';
|
part 'model/avatar_update.dart';
|
||||||
|
part 'model/bulk_id_error_reason.dart';
|
||||||
part 'model/bulk_id_response_dto.dart';
|
part 'model/bulk_id_response_dto.dart';
|
||||||
part 'model/bulk_ids_dto.dart';
|
part 'model/bulk_ids_dto.dart';
|
||||||
part 'model/clip_config.dart';
|
part 'model/clip_config.dart';
|
||||||
|
|||||||
67
mobile/openapi/lib/api/albums_api.dart
generated
67
mobile/openapi/lib/api/albums_api.dart
generated
@@ -91,6 +91,73 @@ class AlbumsApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This endpoint requires the `albumAsset.create` permission.
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
|
||||||
|
///
|
||||||
|
/// * [String] key:
|
||||||
|
///
|
||||||
|
/// * [String] slug:
|
||||||
|
Future<Response> addAssetsToAlbumsWithHttpInfo(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/albums/assets';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = albumsAddAssetsDto;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'key', key));
|
||||||
|
}
|
||||||
|
if (slug != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'PUT',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This endpoint requires the `albumAsset.create` permission.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
|
||||||
|
///
|
||||||
|
/// * [String] key:
|
||||||
|
///
|
||||||
|
/// * [String] slug:
|
||||||
|
Future<AlbumsAddAssetsResponseDto?> addAssetsToAlbums(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
|
||||||
|
final response = await addAssetsToAlbumsWithHttpInfo(albumsAddAssetsDto, key: key, slug: slug, );
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumsAddAssetsResponseDto',) as AlbumsAddAssetsResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// This endpoint requires the `albumUser.create` permission.
|
/// This endpoint requires the `albumUser.create` permission.
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
|||||||
41
mobile/openapi/lib/api/api_keys_api.dart
generated
41
mobile/openapi/lib/api/api_keys_api.dart
generated
@@ -213,6 +213,47 @@ class APIKeysApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /api-keys/me' operation and returns the [Response].
|
||||||
|
Future<Response> getMyApiKeyWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/api-keys/me';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<APIKeyResponseDto?> getMyApiKey() async {
|
||||||
|
final response = await getMyApiKeyWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'APIKeyResponseDto',) as APIKeyResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// This endpoint requires the `apiKey.update` permission.
|
/// This endpoint requires the `apiKey.update` permission.
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
|||||||
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
@@ -212,6 +212,10 @@ class ApiClient {
|
|||||||
return AlbumUserResponseDto.fromJson(value);
|
return AlbumUserResponseDto.fromJson(value);
|
||||||
case 'AlbumUserRole':
|
case 'AlbumUserRole':
|
||||||
return AlbumUserRoleTypeTransformer().decode(value);
|
return AlbumUserRoleTypeTransformer().decode(value);
|
||||||
|
case 'AlbumsAddAssetsDto':
|
||||||
|
return AlbumsAddAssetsDto.fromJson(value);
|
||||||
|
case 'AlbumsAddAssetsResponseDto':
|
||||||
|
return AlbumsAddAssetsResponseDto.fromJson(value);
|
||||||
case 'AlbumsResponse':
|
case 'AlbumsResponse':
|
||||||
return AlbumsResponse.fromJson(value);
|
return AlbumsResponse.fromJson(value);
|
||||||
case 'AlbumsUpdate':
|
case 'AlbumsUpdate':
|
||||||
@@ -280,6 +284,8 @@ class ApiClient {
|
|||||||
return AuthStatusResponseDto.fromJson(value);
|
return AuthStatusResponseDto.fromJson(value);
|
||||||
case 'AvatarUpdate':
|
case 'AvatarUpdate':
|
||||||
return AvatarUpdate.fromJson(value);
|
return AvatarUpdate.fromJson(value);
|
||||||
|
case 'BulkIdErrorReason':
|
||||||
|
return BulkIdErrorReasonTypeTransformer().decode(value);
|
||||||
case 'BulkIdResponseDto':
|
case 'BulkIdResponseDto':
|
||||||
return BulkIdResponseDto.fromJson(value);
|
return BulkIdResponseDto.fromJson(value);
|
||||||
case 'BulkIdsDto':
|
case 'BulkIdsDto':
|
||||||
|
|||||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -79,6 +79,9 @@ String parameterToString(dynamic value) {
|
|||||||
if (value is AudioCodec) {
|
if (value is AudioCodec) {
|
||||||
return AudioCodecTypeTransformer().encode(value).toString();
|
return AudioCodecTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
if (value is BulkIdErrorReason) {
|
||||||
|
return BulkIdErrorReasonTypeTransformer().encode(value).toString();
|
||||||
|
}
|
||||||
if (value is CQMode) {
|
if (value is CQMode) {
|
||||||
return CQModeTypeTransformer().encode(value).toString();
|
return CQModeTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
|||||||
111
mobile/openapi/lib/model/albums_add_assets_dto.dart
generated
Normal file
111
mobile/openapi/lib/model/albums_add_assets_dto.dart
generated
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AlbumsAddAssetsDto {
|
||||||
|
/// Returns a new [AlbumsAddAssetsDto] instance.
|
||||||
|
AlbumsAddAssetsDto({
|
||||||
|
this.albumIds = const [],
|
||||||
|
this.assetIds = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> albumIds;
|
||||||
|
|
||||||
|
List<String> assetIds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsDto &&
|
||||||
|
_deepEquality.equals(other.albumIds, albumIds) &&
|
||||||
|
_deepEquality.equals(other.assetIds, assetIds);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(albumIds.hashCode) +
|
||||||
|
(assetIds.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AlbumsAddAssetsDto[albumIds=$albumIds, assetIds=$assetIds]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'albumIds'] = this.albumIds;
|
||||||
|
json[r'assetIds'] = this.assetIds;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AlbumsAddAssetsDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AlbumsAddAssetsDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AlbumsAddAssetsDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AlbumsAddAssetsDto(
|
||||||
|
albumIds: json[r'albumIds'] is Iterable
|
||||||
|
? (json[r'albumIds'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
assetIds: json[r'assetIds'] is Iterable
|
||||||
|
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AlbumsAddAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AlbumsAddAssetsDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AlbumsAddAssetsDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AlbumsAddAssetsDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AlbumsAddAssetsDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AlbumsAddAssetsDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AlbumsAddAssetsDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AlbumsAddAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AlbumsAddAssetsDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AlbumsAddAssetsDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'albumIds',
|
||||||
|
'assetIds',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
132
mobile/openapi/lib/model/albums_add_assets_response_dto.dart
generated
Normal file
132
mobile/openapi/lib/model/albums_add_assets_response_dto.dart
generated
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AlbumsAddAssetsResponseDto {
|
||||||
|
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
|
||||||
|
AlbumsAddAssetsResponseDto({
|
||||||
|
required this.albumSuccessCount,
|
||||||
|
required this.assetSuccessCount,
|
||||||
|
this.error,
|
||||||
|
required this.success,
|
||||||
|
});
|
||||||
|
|
||||||
|
int albumSuccessCount;
|
||||||
|
|
||||||
|
int assetSuccessCount;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
BulkIdErrorReason? error;
|
||||||
|
|
||||||
|
bool success;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
|
||||||
|
other.albumSuccessCount == albumSuccessCount &&
|
||||||
|
other.assetSuccessCount == assetSuccessCount &&
|
||||||
|
other.error == error &&
|
||||||
|
other.success == success;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(albumSuccessCount.hashCode) +
|
||||||
|
(assetSuccessCount.hashCode) +
|
||||||
|
(error == null ? 0 : error!.hashCode) +
|
||||||
|
(success.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'albumSuccessCount'] = this.albumSuccessCount;
|
||||||
|
json[r'assetSuccessCount'] = this.assetSuccessCount;
|
||||||
|
if (this.error != null) {
|
||||||
|
json[r'error'] = this.error;
|
||||||
|
} else {
|
||||||
|
// json[r'error'] = null;
|
||||||
|
}
|
||||||
|
json[r'success'] = this.success;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AlbumsAddAssetsResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AlbumsAddAssetsResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AlbumsAddAssetsResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AlbumsAddAssetsResponseDto(
|
||||||
|
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
|
||||||
|
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
|
||||||
|
error: BulkIdErrorReason.fromJson(json[r'error']),
|
||||||
|
success: mapValueOfType<bool>(json, r'success')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AlbumsAddAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AlbumsAddAssetsResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AlbumsAddAssetsResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AlbumsAddAssetsResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AlbumsAddAssetsResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AlbumsAddAssetsResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AlbumsAddAssetsResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AlbumsAddAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AlbumsAddAssetsResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AlbumsAddAssetsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'albumSuccessCount',
|
||||||
|
'assetSuccessCount',
|
||||||
|
'success',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
91
mobile/openapi/lib/model/bulk_id_error_reason.dart
generated
Normal file
91
mobile/openapi/lib/model/bulk_id_error_reason.dart
generated
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
|
||||||
|
class BulkIdErrorReason {
|
||||||
|
/// Instantiate a new enum with the provided [value].
|
||||||
|
const BulkIdErrorReason._(this.value);
|
||||||
|
|
||||||
|
/// The underlying value of this enum member.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => value;
|
||||||
|
|
||||||
|
String toJson() => value;
|
||||||
|
|
||||||
|
static const duplicate = BulkIdErrorReason._(r'duplicate');
|
||||||
|
static const noPermission = BulkIdErrorReason._(r'no_permission');
|
||||||
|
static const notFound = BulkIdErrorReason._(r'not_found');
|
||||||
|
static const unknown = BulkIdErrorReason._(r'unknown');
|
||||||
|
|
||||||
|
/// List of all possible values in this [enum][BulkIdErrorReason].
|
||||||
|
static const values = <BulkIdErrorReason>[
|
||||||
|
duplicate,
|
||||||
|
noPermission,
|
||||||
|
notFound,
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
|
|
||||||
|
static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value);
|
||||||
|
|
||||||
|
static List<BulkIdErrorReason> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <BulkIdErrorReason>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = BulkIdErrorReason.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformation class that can [encode] an instance of [BulkIdErrorReason] to String,
|
||||||
|
/// and [decode] dynamic data back to [BulkIdErrorReason].
|
||||||
|
class BulkIdErrorReasonTypeTransformer {
|
||||||
|
factory BulkIdErrorReasonTypeTransformer() => _instance ??= const BulkIdErrorReasonTypeTransformer._();
|
||||||
|
|
||||||
|
const BulkIdErrorReasonTypeTransformer._();
|
||||||
|
|
||||||
|
String encode(BulkIdErrorReason data) => data.value;
|
||||||
|
|
||||||
|
/// Decodes a [dynamic value][data] to a BulkIdErrorReason.
|
||||||
|
///
|
||||||
|
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||||
|
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||||
|
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||||
|
///
|
||||||
|
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||||
|
/// and users are still using an old app with the old code.
|
||||||
|
BulkIdErrorReason? decode(dynamic data, {bool allowNull = true}) {
|
||||||
|
if (data != null) {
|
||||||
|
switch (data) {
|
||||||
|
case r'duplicate': return BulkIdErrorReason.duplicate;
|
||||||
|
case r'no_permission': return BulkIdErrorReason.noPermission;
|
||||||
|
case r'not_found': return BulkIdErrorReason.notFound;
|
||||||
|
case r'unknown': return BulkIdErrorReason.unknown;
|
||||||
|
default:
|
||||||
|
if (!allowNull) {
|
||||||
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Singleton [BulkIdErrorReasonTypeTransformer] instance.
|
||||||
|
static BulkIdErrorReasonTypeTransformer? _instance;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.137.3+3002
|
version: 1.138.1+3004
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
|
|||||||
118
mobile/test/domain/services/album.service_test.dart
Normal file
118
mobile/test/domain/services/album.service_test.dart
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late RemoteAlbumService sut;
|
||||||
|
late DriftRemoteAlbumRepository mockRemoteAlbumRepo;
|
||||||
|
late DriftAlbumApiRepository mockAlbumApiRepo;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
|
||||||
|
mockAlbumApiRepo = MockDriftAlbumApiRepository();
|
||||||
|
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
|
||||||
|
|
||||||
|
when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) {
|
||||||
|
// Simulate a timestamp for the newest asset in the album
|
||||||
|
final albumID = invocation.positionalArguments[0] as String;
|
||||||
|
|
||||||
|
if (albumID == '1') {
|
||||||
|
return Future.value(DateTime(2023, 1, 1));
|
||||||
|
} else if (albumID == '2') {
|
||||||
|
return Future.value(DateTime(2023, 2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) {
|
||||||
|
// Simulate a timestamp for the oldest asset in the album
|
||||||
|
final albumID = invocation.positionalArguments[0] as String;
|
||||||
|
|
||||||
|
if (albumID == '1') {
|
||||||
|
return Future.value(DateTime(2019, 1, 1));
|
||||||
|
} else if (albumID == '2') {
|
||||||
|
return Future.value(DateTime(2019, 2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
final albumA = RemoteAlbum(
|
||||||
|
id: '1',
|
||||||
|
name: 'Album A',
|
||||||
|
description: "",
|
||||||
|
isActivityEnabled: false,
|
||||||
|
order: AlbumAssetOrder.asc,
|
||||||
|
assetCount: 1,
|
||||||
|
createdAt: DateTime(2023, 1, 1),
|
||||||
|
updatedAt: DateTime(2023, 1, 2),
|
||||||
|
ownerId: 'owner1',
|
||||||
|
ownerName: "Test User",
|
||||||
|
isShared: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final albumB = RemoteAlbum(
|
||||||
|
id: '2',
|
||||||
|
name: 'Album B',
|
||||||
|
description: "",
|
||||||
|
isActivityEnabled: false,
|
||||||
|
order: AlbumAssetOrder.desc,
|
||||||
|
assetCount: 2,
|
||||||
|
createdAt: DateTime(2023, 2, 1),
|
||||||
|
updatedAt: DateTime(2023, 2, 2),
|
||||||
|
ownerId: 'owner2',
|
||||||
|
ownerName: "Test User",
|
||||||
|
isShared: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
group('sortAlbums', () {
|
||||||
|
test('should sort correctly based on name', () async {
|
||||||
|
final albums = [albumB, albumA];
|
||||||
|
|
||||||
|
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.title);
|
||||||
|
expect(result, [albumA, albumB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort correctly based on createdAt', () async {
|
||||||
|
final albums = [albumB, albumA];
|
||||||
|
|
||||||
|
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.created);
|
||||||
|
expect(result, [albumA, albumB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort correctly based on updatedAt', () async {
|
||||||
|
final albums = [albumB, albumA];
|
||||||
|
|
||||||
|
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.lastModified);
|
||||||
|
expect(result, [albumA, albumB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort correctly based on assetCount', () async {
|
||||||
|
final albums = [albumB, albumA];
|
||||||
|
|
||||||
|
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.assetCount);
|
||||||
|
expect(result, [albumA, albumB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort correctly based on newestAssetTimestamp', () async {
|
||||||
|
final albums = [albumB, albumA];
|
||||||
|
|
||||||
|
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostRecent);
|
||||||
|
expect(result, [albumA, albumB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort correctly based on oldestAssetTimestamp', () async {
|
||||||
|
final albums = [albumB, albumA];
|
||||||
|
|
||||||
|
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostOldest);
|
||||||
|
expect(result, [albumB, albumA]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import 'package:immich_mobile/infrastructure/repositories/device_asset.repositor
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockStoreRepository extends Mock implements IsarStoreRepository {}
|
class MockStoreRepository extends Mock implements IsarStoreRepository {}
|
||||||
@@ -22,6 +24,8 @@ class MockSyncStreamRepository extends Mock implements SyncStreamRepository {}
|
|||||||
|
|
||||||
class MockLocalAlbumRepository extends Mock implements DriftLocalAlbumRepository {}
|
class MockLocalAlbumRepository extends Mock implements DriftLocalAlbumRepository {}
|
||||||
|
|
||||||
|
class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumRepository {}
|
||||||
|
|
||||||
class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
|
class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
|
||||||
|
|
||||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||||
@@ -30,3 +34,5 @@ class MockStorageRepository extends Mock implements StorageRepository {}
|
|||||||
class MockUserApiRepository extends Mock implements UserApiRepository {}
|
class MockUserApiRepository extends Mock implements UserApiRepository {}
|
||||||
|
|
||||||
class MockSyncApiRepository extends Mock implements SyncApiRepository {}
|
class MockSyncApiRepository extends Mock implements SyncApiRepository {}
|
||||||
|
|
||||||
|
class MockDriftAlbumApiRepository extends Mock implements DriftAlbumApiRepository {}
|
||||||
|
|||||||
717
mobile/test/utils/action_button_utils_test.dart
Normal file
717
mobile/test/utils/action_button_utils_test.dart
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
|
|
||||||
|
LocalAsset createLocalAsset({
|
||||||
|
String? remoteId,
|
||||||
|
String name = 'test.jpg',
|
||||||
|
String? checksum = 'test-checksum',
|
||||||
|
AssetType type = AssetType.image,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
bool isFavorite = false,
|
||||||
|
}) {
|
||||||
|
return LocalAsset(
|
||||||
|
id: 'local-id',
|
||||||
|
remoteId: remoteId,
|
||||||
|
name: name,
|
||||||
|
checksum: checksum,
|
||||||
|
type: type,
|
||||||
|
createdAt: createdAt ?? DateTime.now(),
|
||||||
|
updatedAt: updatedAt ?? DateTime.now(),
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteAsset createRemoteAsset({
|
||||||
|
String? localId,
|
||||||
|
String name = 'test.jpg',
|
||||||
|
String checksum = 'test-checksum',
|
||||||
|
AssetType type = AssetType.image,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
bool isFavorite = false,
|
||||||
|
}) {
|
||||||
|
return RemoteAsset(
|
||||||
|
id: 'remote-id',
|
||||||
|
localId: localId,
|
||||||
|
name: name,
|
||||||
|
checksum: checksum,
|
||||||
|
type: type,
|
||||||
|
ownerId: 'owner-id',
|
||||||
|
createdAt: createdAt ?? DateTime.now(),
|
||||||
|
updatedAt: updatedAt ?? DateTime.now(),
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteAlbum createRemoteAlbum({
|
||||||
|
String id = 'test-album-id',
|
||||||
|
String name = 'Test Album',
|
||||||
|
bool isActivityEnabled = false,
|
||||||
|
bool isShared = false,
|
||||||
|
}) {
|
||||||
|
return RemoteAlbum(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
ownerId: 'owner-id',
|
||||||
|
description: 'Test Description',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
isActivityEnabled: isActivityEnabled,
|
||||||
|
isShared: isShared,
|
||||||
|
order: AlbumAssetOrder.asc,
|
||||||
|
assetCount: 0,
|
||||||
|
ownerName: 'Test Owner',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ActionButtonContext', () {
|
||||||
|
test('should create context with all required parameters', () {
|
||||||
|
final asset = createLocalAsset();
|
||||||
|
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: asset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context.asset, isA<BaseAsset>());
|
||||||
|
expect(context.isOwner, isTrue);
|
||||||
|
expect(context.isArchived, isFalse);
|
||||||
|
expect(context.isTrashEnabled, isTrue);
|
||||||
|
expect(context.isInLockedView, isFalse);
|
||||||
|
expect(context.currentAlbum, isNull);
|
||||||
|
expect(context.source, ActionSource.timeline);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ActionButtonType.shouldShow', () {
|
||||||
|
late BaseAsset mergedAsset;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mergedAsset = createLocalAsset(remoteId: 'remote-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
group('share button', () {
|
||||||
|
test('should show when not in locked view', () {
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.share.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show when in locked view', () {
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: true,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.share.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('shareLink button', () {
|
||||||
|
test('should show when not in locked view and asset has remote', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.shareLink.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when in locked view', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: true,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when asset has no remote', () {
|
||||||
|
final localAsset = createLocalAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: localAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('archive button', () {
|
||||||
|
test('should show when owner, not locked, has remote, and not archived', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.archive.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when not owner', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: false,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.archive.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when in locked view', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: true,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.archive.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when asset has no remote', () {
|
||||||
|
final localAsset = createLocalAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: localAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.archive.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when already archived', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: true,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.archive.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('unarchive button', () {
|
||||||
|
test('should show when owner, not locked, has remote, and is archived', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: true,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.unarchive.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when not archived', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when not owner', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: false,
|
||||||
|
isArchived: true,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('download button', () {
|
||||||
|
test('should show when not locked, has remote, and no local copy', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.download.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when has local copy', () {
|
||||||
|
final mergedAsset = createLocalAsset(remoteId: 'remote-id');
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.download.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when in locked view', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: true,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.download.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('trash button', () {
|
||||||
|
test('should show when owner, not locked, has remote, and trash enabled', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.trash.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when trash disabled', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: false,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.trash.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('deletePermanent button', () {
|
||||||
|
test('should show when owner, not locked, has remote, and trash disabled', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: false,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when trash enabled', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('delete button', () {
|
||||||
|
test('should show when owner, not locked, and has remote', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.delete.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('moveToLockFolder button', () {
|
||||||
|
test('should show when owner, not locked, and has remote', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('deleteLocal button', () {
|
||||||
|
test('should show when not locked and asset is local only', () {
|
||||||
|
final localAsset = createLocalAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: localAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when asset is not local only', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('upload button', () {
|
||||||
|
test('should show when not locked and asset is local only', () {
|
||||||
|
final localAsset = createLocalAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: localAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.upload.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('removeFromAlbum button', () {
|
||||||
|
test('should show when owner, not locked, and has current album', () {
|
||||||
|
final album = createRemoteAlbum();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: album,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when no current album', () {
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('likeActivity button', () {
|
||||||
|
test('should show when not locked, has album, activity enabled, and shared', () {
|
||||||
|
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: album,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.likeActivity.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when activity not enabled', () {
|
||||||
|
final album = createRemoteAlbum(isActivityEnabled: false, isShared: true);
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: album,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when album not shared', () {
|
||||||
|
final album = createRemoteAlbum(isActivityEnabled: true, isShared: false);
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: album,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when no album', () {
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ActionButtonType.buildButton', () {
|
||||||
|
late BaseAsset asset;
|
||||||
|
late ActionButtonContext context;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
asset = createLocalAsset(remoteId: 'remote-id');
|
||||||
|
context = ActionButtonContext(
|
||||||
|
asset: asset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should build correct widget for each button type', () {
|
||||||
|
for (final buttonType in ActionButtonType.values) {
|
||||||
|
if (buttonType == ActionButtonType.removeFromAlbum) {
|
||||||
|
final album = createRemoteAlbum();
|
||||||
|
final contextWithAlbum = ActionButtonContext(
|
||||||
|
asset: asset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: album,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
final widget = buttonType.buildButton(contextWithAlbum);
|
||||||
|
expect(widget, isA<Widget>());
|
||||||
|
} else {
|
||||||
|
final widget = buttonType.buildButton(context);
|
||||||
|
expect(widget, isA<Widget>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ActionButtonBuilder', () {
|
||||||
|
test('should return buttons that should show', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
final widgets = ActionButtonBuilder.build(context);
|
||||||
|
|
||||||
|
expect(widgets, isNotEmpty);
|
||||||
|
expect(widgets.length, greaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include album-specific buttons when album is present', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: album,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
final widgets = ActionButtonBuilder.build(context);
|
||||||
|
|
||||||
|
expect(widgets, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should only include local buttons for local assets', () {
|
||||||
|
final localAsset = createLocalAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: localAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
final widgets = ActionButtonBuilder.build(context);
|
||||||
|
|
||||||
|
expect(widgets, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should respect archived state', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
|
||||||
|
final archivedContext = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: true,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
final archivedWidgets = ActionButtonBuilder.build(archivedContext);
|
||||||
|
|
||||||
|
final nonArchivedContext = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
final nonArchivedWidgets = ActionButtonBuilder.build(nonArchivedContext);
|
||||||
|
|
||||||
|
expect(archivedWidgets, isNotEmpty);
|
||||||
|
expect(nonArchivedWidgets, isNotEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ function dart {
|
|||||||
patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch
|
patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch
|
||||||
|
|
||||||
cd ../../
|
cd ../../
|
||||||
npx --yes @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
|
pnpx @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
|
||||||
|
|
||||||
# Post generate patches
|
# Post generate patches
|
||||||
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch
|
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch
|
||||||
@@ -27,12 +27,17 @@ function dart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function typescript {
|
function typescript {
|
||||||
npx --yes oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
|
pnpx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
|
||||||
npm --prefix typescript-sdk ci && npm --prefix typescript-sdk run build
|
pnpm --filter @immich/sdk install --frozen-lockfile
|
||||||
|
pnpm --filter @immich/sdk build
|
||||||
}
|
}
|
||||||
|
|
||||||
# requires server to be built
|
# requires server to be built
|
||||||
npm run sync:open-api --prefix=../server
|
(
|
||||||
|
cd ..
|
||||||
|
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich build
|
||||||
|
pnpm --filter immich sync:open-api
|
||||||
|
)
|
||||||
|
|
||||||
if [[ $1 == 'dart' ]]; then
|
if [[ $1 == 'dart' ]]; then
|
||||||
dart
|
dart
|
||||||
|
|||||||
@@ -940,6 +940,67 @@
|
|||||||
"description": "This endpoint requires the `album.create` permission."
|
"description": "This endpoint requires the `album.create` permission."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/albums/assets": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "addAssetsToAlbums",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "slug",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AlbumsAddAssetsDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AlbumsAddAssetsResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Albums"
|
||||||
|
],
|
||||||
|
"x-immich-permission": "albumAsset.create",
|
||||||
|
"description": "This endpoint requires the `albumAsset.create` permission."
|
||||||
|
}
|
||||||
|
},
|
||||||
"/albums/statistics": {
|
"/albums/statistics": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAlbumStatistics",
|
"operationId": "getAlbumStatistics",
|
||||||
@@ -1488,6 +1549,38 @@
|
|||||||
"description": "This endpoint requires the `apiKey.create` permission."
|
"description": "This endpoint requires the `apiKey.create` permission."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api-keys/me": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getMyApiKey",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"API Keys"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api-keys/{id}": {
|
"/api-keys/{id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"operationId": "deleteApiKey",
|
"operationId": "deleteApiKey",
|
||||||
@@ -9499,7 +9592,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.137.3",
|
"version": "1.138.1",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
@@ -9889,6 +9982,55 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AlbumsAddAssetsDto": {
|
||||||
|
"properties": {
|
||||||
|
"albumIds": {
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"assetIds": {
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"albumIds",
|
||||||
|
"assetIds"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AlbumsAddAssetsResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"albumSuccessCount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"assetSuccessCount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/BulkIdErrorReason"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"albumSuccessCount",
|
||||||
|
"assetSuccessCount",
|
||||||
|
"success"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AlbumsResponse": {
|
"AlbumsResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"defaultAssetOrder": {
|
"defaultAssetOrder": {
|
||||||
@@ -10845,6 +10987,15 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"BulkIdErrorReason": {
|
||||||
|
"enum": [
|
||||||
|
"duplicate",
|
||||||
|
"no_permission",
|
||||||
|
"not_found",
|
||||||
|
"unknown"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"BulkIdResponseDto": {
|
"BulkIdResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"error": {
|
"error": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ A TypeScript SDK for interfacing with the [Immich](https://immich.app/) API.
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i --save @immich/sdk
|
pnpm i --save @immich/sdk
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
57
open-api/typescript-sdk/package-lock.json
generated
57
open-api/typescript-sdk/package-lock.json
generated
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@immich/sdk",
|
|
||||||
"version": "1.137.3",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "@immich/sdk",
|
|
||||||
"version": "1.137.3",
|
|
||||||
"license": "GNU Affero General Public License version 3",
|
|
||||||
"dependencies": {
|
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.17.0",
|
|
||||||
"typescript": "^5.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@oazapfts/runtime": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@oazapfts/runtime/-/runtime-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-7t6C2shug/6tZhQgkCa532oTYBLEnbASV/i1SG1rH2GB4h3aQQujYciYSPT92hvN4IwTe8S2hPkN/6iiOyTlCg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "22.17.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz",
|
|
||||||
"integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~6.21.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
|
||||||
"version": "6.21.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.137.3",
|
"version": "1.138.1",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 1.137.3
|
* 1.138.1
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
@@ -384,6 +384,16 @@ export type CreateAlbumDto = {
|
|||||||
assetIds?: string[];
|
assetIds?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
export type AlbumsAddAssetsDto = {
|
||||||
|
albumIds: string[];
|
||||||
|
assetIds: string[];
|
||||||
|
};
|
||||||
|
export type AlbumsAddAssetsResponseDto = {
|
||||||
|
albumSuccessCount: number;
|
||||||
|
assetSuccessCount: number;
|
||||||
|
error?: BulkIdErrorReason;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
export type AlbumStatisticsResponseDto = {
|
export type AlbumStatisticsResponseDto = {
|
||||||
notShared: number;
|
notShared: number;
|
||||||
owned: number;
|
owned: number;
|
||||||
@@ -1864,6 +1874,26 @@ export function createAlbum({ createAlbumDto }: {
|
|||||||
body: createAlbumDto
|
body: createAlbumDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This endpoint requires the `albumAsset.create` permission.
|
||||||
|
*/
|
||||||
|
export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: {
|
||||||
|
key?: string;
|
||||||
|
slug?: string;
|
||||||
|
albumsAddAssetsDto: AlbumsAddAssetsDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AlbumsAddAssetsResponseDto;
|
||||||
|
}>(`/albums/assets${QS.query(QS.explode({
|
||||||
|
key,
|
||||||
|
slug
|
||||||
|
}))}`, oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "PUT",
|
||||||
|
body: albumsAddAssetsDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This endpoint requires the `album.statistics` permission.
|
* This endpoint requires the `album.statistics` permission.
|
||||||
*/
|
*/
|
||||||
@@ -2027,6 +2057,14 @@ export function createApiKey({ apiKeyCreateDto }: {
|
|||||||
body: apiKeyCreateDto
|
body: apiKeyCreateDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
export function getMyApiKey(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: ApiKeyResponseDto;
|
||||||
|
}>("/api-keys/me", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This endpoint requires the `apiKey.delete` permission.
|
* This endpoint requires the `apiKey.delete` permission.
|
||||||
*/
|
*/
|
||||||
@@ -4545,6 +4583,12 @@ export enum AssetTypeEnum {
|
|||||||
Audio = "AUDIO",
|
Audio = "AUDIO",
|
||||||
Other = "OTHER"
|
Other = "OTHER"
|
||||||
}
|
}
|
||||||
|
export enum BulkIdErrorReason {
|
||||||
|
Duplicate = "duplicate",
|
||||||
|
NoPermission = "no_permission",
|
||||||
|
NotFound = "not_found",
|
||||||
|
Unknown = "unknown"
|
||||||
|
}
|
||||||
export enum Error {
|
export enum Error {
|
||||||
Duplicate = "duplicate",
|
Duplicate = "duplicate",
|
||||||
NoPermission = "no_permission",
|
NoPermission = "no_permission",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user