Compare commits

..

2 Commits

Author SHA1 Message Date
bwees cc9ec29c83 handle more interactions like uploading and single asset links 2025-08-01 08:21:35 -05:00
bwees c8c6f86518 feat: view shared links inside of mobile app 2025-07-31 13:38:14 -05:00
161 changed files with 2460 additions and 3154 deletions
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.17.1
-96
View File
@@ -1,96 +0,0 @@
on:
issues:
types: [opened]
discussion:
types: [created]
name: Close likely duplicates
permissions: {}
jobs:
get_body:
runs-on: ubuntu-latest
env:
EVENT: ${{ toJSON(github.event) }}
outputs:
body: ${{ steps.get_body.outputs.body }}
steps:
- id: get_body
run: |
BODY=$(echo """$EVENT""" | jq -r '.issue // .discussion | .body' | base64 -w 0)
echo "body=$BODY" >> $GITHUB_OUTPUT
get_checkbox_json:
runs-on: ubuntu-latest
needs: get_body
container:
image: yshavit/mdq:0.7.2
outputs:
json: ${{ steps.get_checkbox.outputs.json }}
steps:
- id: get_checkbox
env:
BODY: ${{ needs.get_body.outputs.body }}
run: |
JSON=$(echo "$BODY" | base64 -d | /mdq --output json '# I have searched | - [?] Yes')
echo "json=$JSON" >> $GITHUB_OUTPUT
close_and_comment:
runs-on: ubuntu-latest
needs: get_checkbox_json
if: ${{ !fromJSON(needs.get_checkbox_json.outputs.json).items[0].list[0].checked }}
permissions:
issues: write
discussions: write
steps:
- name: Close issue
if: ${{ github.event_name == 'issues' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.issue.node_id }}
run: |
gh api graphql \
-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 query='
mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
addComment(input: {
subjectId: $issueId,
body: $body
}) {
__typename
}
closeIssue(input: {
issueId: $issueId,
stateReason: DUPLICATE
}) {
__typename
}
}'
- name: Close discussion
if: ${{ github.event_name == 'discussion' && github.event.discussion.category.name == 'Feature Request' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.discussion.node_id }}
run: |
gh api graphql \
-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 query='
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
addDiscussionComment(input: {
discussionId: $discussionId,
body: $body
}) {
__typename
}
closeDiscussion(input: {
discussionId: $discussionId,
reason: DUPLICATE
}) {
__typename
}
}'
+3 -3
View File
@@ -50,7 +50,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -63,7 +63,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 uses: github/codeql-action/autobuild@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -76,6 +76,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -129,7 +129,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file - name: Upload SARIF file
uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 uses: github/codeql-action/upload-sarif@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
category: zizmor category: zizmor
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.17.1
+105 -73
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.77", "version": "2.2.73",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.77", "version": "2.2.73",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -27,7 +27,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.17.0", "@types/node": "^22.16.5",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -35,7 +35,7 @@
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^59.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -54,14 +54,14 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.137.3", "version": "1.136.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.17.0", "@types/node": "^22.16.5",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -90,9 +90,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1", "version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -632,9 +632,9 @@
} }
}, },
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "0.15.1", "version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -682,9 +682,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.32.0", "version": "9.31.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
"integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -705,13 +705,13 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.3.4", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.15.1", "@eslint/core": "^0.14.0",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
@@ -1355,9 +1355,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.17.0", "version": "22.16.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz",
"integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1897,9 +1897,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.1", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1917,10 +1917,10 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.73",
"node-releases": "^2.0.19", "node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3" "update-browserslist-db": "^1.1.1"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@@ -1981,9 +1981,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001731", "version": "1.0.30001713",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
"integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", "integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2035,13 +2035,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/check-error": { "node_modules/check-error": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -2068,9 +2061,9 @@
} }
}, },
"node_modules/ci-info": { "node_modules/ci-info": {
"version": "4.3.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz",
"integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2157,13 +2150,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.45.0", "version": "3.41.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
"integrity": "sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA==", "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.25.1" "browserslist": "^4.24.4"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -2228,9 +2221,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.195", "version": "1.5.137",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.195.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz",
"integrity": "sha512-URclP0iIaDUzqcAyV1v2PgduJ9N0IdXmWsnPzPfelvBmjmZzEy6xJcjb1cXj+TbYqXgtLrjHEoaSIdTYhw4ezg==", "integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -2313,9 +2306,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.32.0", "version": "9.31.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2325,8 +2318,8 @@
"@eslint/config-helpers": "^0.3.0", "@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.15.0", "@eslint/core": "^0.15.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.32.0", "@eslint/js": "9.31.0",
"@eslint/plugin-kit": "^0.3.4", "@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
@@ -2421,39 +2414,65 @@
} }
}, },
"node_modules/eslint-plugin-unicorn": { "node_modules/eslint-plugin-unicorn": {
"version": "60.0.0", "version": "59.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-60.0.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-59.0.1.tgz",
"integrity": "sha512-QUzTefvP8stfSXsqKQ+vBQSEsXIlAiCduS/V1Em+FKgL9c21U/IIm20/e3MFy1jyCf14tHAhqC1sX8OTy6VUCg==", "integrity": "sha512-EtNXYuWPUmkgSU2E7Ttn57LbRREQesIP1BiLn7OZLKodopKfDXfBUkC/0j6mpw2JExwf43Uf3qLSvrSvppgy8Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.27.1", "@babel/helper-validator-identifier": "^7.25.9",
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.5.1",
"@eslint/plugin-kit": "^0.3.3", "@eslint/plugin-kit": "^0.2.7",
"change-case": "^5.4.4", "ci-info": "^4.2.0",
"ci-info": "^4.3.0",
"clean-regexp": "^1.0.0", "clean-regexp": "^1.0.0",
"core-js-compat": "^3.44.0", "core-js-compat": "^3.41.0",
"esquery": "^1.6.0", "esquery": "^1.6.0",
"find-up-simple": "^1.0.1", "find-up-simple": "^1.0.1",
"globals": "^16.3.0", "globals": "^16.0.0",
"indent-string": "^5.0.0", "indent-string": "^5.0.0",
"is-builtin-module": "^5.0.0", "is-builtin-module": "^5.0.0",
"jsesc": "^3.1.0", "jsesc": "^3.1.0",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"regexp-tree": "^0.1.27", "regexp-tree": "^0.1.27",
"regjsparser": "^0.12.0", "regjsparser": "^0.12.0",
"semver": "^7.7.2", "semver": "^7.7.1",
"strip-indent": "^4.0.0" "strip-indent": "^4.0.0"
}, },
"engines": { "engines": {
"node": "^20.10.0 || >=21.0.0" "node": "^18.20.0 || ^20.10.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=9.29.0" "eslint": ">=9.22.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/@eslint/core": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
"integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/@eslint/plugin-kit": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz",
"integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.13.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
@@ -2486,6 +2505,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/@eslint/core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/espree": { "node_modules/espree": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -3695,9 +3727,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.2", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -4179,15 +4211,15 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.0.6", "version": "7.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz",
"integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", "integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.6", "fdir": "^6.4.6",
"picomatch": "^4.0.3", "picomatch": "^4.0.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup": "^4.40.0", "rollup": "^4.40.0",
"tinyglobby": "^0.2.14" "tinyglobby": "^0.2.14"
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.77", "version": "2.2.73",
"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,7 +21,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.17.0", "@types/node": "^22.16.5",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -29,7 +29,7 @@
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^59.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8" "micromatch": "^4.0.8"
}, },
"volta": { "volta": {
"node": "22.18.0" "node": "22.17.1"
} }
} }
+1 -1
View File
@@ -95,7 +95,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:12.1.0-ubuntu@sha256:397aa30dd1af16cb6c5c9879498e467973a7f87eacf949f6d5a29407a3843809 image: grafana/grafana:12.0.2-ubuntu@sha256:0512d81cdeaaff0e370a9aa66027b465d1f1f04379c3a9c801a905fabbdbc7a5
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.17.1
+1 -1
View File
@@ -59,6 +59,6 @@
"node": ">=20" "node": ">=20"
}, },
"volta": { "volta": {
"node": "22.18.0" "node": "22.17.1"
} }
} }
-16
View File
@@ -1,20 +1,4 @@
[ [
{
"label": "v1.137.3",
"url": "https://v1.137.3.archive.immich.app"
},
{
"label": "v1.137.2",
"url": "https://v1.137.2.archive.immich.app"
},
{
"label": "v1.137.1",
"url": "https://v1.137.1.archive.immich.app"
},
{
"label": "v1.137.0",
"url": "https://v1.137.0.archive.immich.app"
},
{ {
"label": "v1.136.0", "label": "v1.136.0",
"url": "https://v1.136.0.archive.immich.app" "url": "https://v1.136.0.archive.immich.app"
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.17.1
+110 -78
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.137.3", "version": "1.136.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.137.3", "version": "1.136.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -16,7 +16,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.17.0", "@types/node": "^22.16.5",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -25,7 +25,7 @@
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^59.0.0",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^28.3.1",
"globals": "^16.0.0", "globals": "^16.0.0",
"jose": "^5.6.3", "jose": "^5.6.3",
@@ -46,7 +46,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.77", "version": "2.2.73",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -68,7 +68,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.17.0", "@types/node": "^22.16.5",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -76,7 +76,7 @@
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^59.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -95,14 +95,14 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.137.3", "version": "1.136.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.17.0", "@types/node": "^22.16.5",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -131,9 +131,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1", "version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -684,9 +684,9 @@
} }
}, },
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "0.15.1", "version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -734,9 +734,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.32.0", "version": "9.31.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
"integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -757,13 +757,13 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.3.4", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.15.1", "@eslint/core": "^0.14.0",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
@@ -1999,9 +1999,9 @@
} }
}, },
"node_modules/@types/luxon": { "node_modules/@types/luxon": {
"version": "3.7.1", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2020,9 +2020,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.17.0", "version": "22.16.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz",
"integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2030,9 +2030,9 @@
} }
}, },
"node_modules/@types/oidc-provider": { "node_modules/@types/oidc-provider": {
"version": "9.1.2", "version": "9.1.1",
"resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-9.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-9.1.1.tgz",
"integrity": "sha512-JAreXkbWsZR72Gt3eigG652wq1qBcjhuy421PXU2a8PS0mM00XlG+UdXbM/QPihM3ko0YF8cwvt0H2kacXGcsg==", "integrity": "sha512-sG4UcE4AbUwAsEpyrcyoqZ383wJiQObZU+gTa1Iv288+l09HwSr88hBZE2IBLlXS+RKmLId0i4B430PBFO/XRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2042,9 +2042,9 @@
} }
}, },
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.15.5", "version": "8.15.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
"integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2741,9 +2741,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.1", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2761,10 +2761,10 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.73",
"node-releases": "^2.0.19", "node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3" "update-browserslist-db": "^1.1.1"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@@ -2862,9 +2862,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001731", "version": "1.0.30001713",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
"integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", "integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2916,13 +2916,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/check-error": { "node_modules/check-error": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -2944,9 +2937,9 @@
} }
}, },
"node_modules/ci-info": { "node_modules/ci-info": {
"version": "4.3.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz",
"integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3119,13 +3112,13 @@
} }
}, },
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.45.0", "version": "3.41.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
"integrity": "sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA==", "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.25.1" "browserslist": "^4.24.4"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -3278,9 +3271,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.195", "version": "1.5.137",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.195.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz",
"integrity": "sha512-URclP0iIaDUzqcAyV1v2PgduJ9N0IdXmWsnPzPfelvBmjmZzEy6xJcjb1cXj+TbYqXgtLrjHEoaSIdTYhw4ezg==", "integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -3471,9 +3464,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.32.0", "version": "9.31.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3483,8 +3476,8 @@
"@eslint/config-helpers": "^0.3.0", "@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.15.0", "@eslint/core": "^0.15.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.32.0", "@eslint/js": "9.31.0",
"@eslint/plugin-kit": "^0.3.4", "@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
@@ -3579,39 +3572,65 @@
} }
}, },
"node_modules/eslint-plugin-unicorn": { "node_modules/eslint-plugin-unicorn": {
"version": "60.0.0", "version": "59.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-60.0.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-59.0.1.tgz",
"integrity": "sha512-QUzTefvP8stfSXsqKQ+vBQSEsXIlAiCduS/V1Em+FKgL9c21U/IIm20/e3MFy1jyCf14tHAhqC1sX8OTy6VUCg==", "integrity": "sha512-EtNXYuWPUmkgSU2E7Ttn57LbRREQesIP1BiLn7OZLKodopKfDXfBUkC/0j6mpw2JExwf43Uf3qLSvrSvppgy8Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.27.1", "@babel/helper-validator-identifier": "^7.25.9",
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.5.1",
"@eslint/plugin-kit": "^0.3.3", "@eslint/plugin-kit": "^0.2.7",
"change-case": "^5.4.4", "ci-info": "^4.2.0",
"ci-info": "^4.3.0",
"clean-regexp": "^1.0.0", "clean-regexp": "^1.0.0",
"core-js-compat": "^3.44.0", "core-js-compat": "^3.41.0",
"esquery": "^1.6.0", "esquery": "^1.6.0",
"find-up-simple": "^1.0.1", "find-up-simple": "^1.0.1",
"globals": "^16.3.0", "globals": "^16.0.0",
"indent-string": "^5.0.0", "indent-string": "^5.0.0",
"is-builtin-module": "^5.0.0", "is-builtin-module": "^5.0.0",
"jsesc": "^3.1.0", "jsesc": "^3.1.0",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"regexp-tree": "^0.1.27", "regexp-tree": "^0.1.27",
"regjsparser": "^0.12.0", "regjsparser": "^0.12.0",
"semver": "^7.7.2", "semver": "^7.7.1",
"strip-indent": "^4.0.0" "strip-indent": "^4.0.0"
}, },
"engines": { "engines": {
"node": "^20.10.0 || >=21.0.0" "node": "^18.20.0 || ^20.10.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=9.29.0" "eslint": ">=9.22.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/@eslint/core": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
"integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/@eslint/plugin-kit": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz",
"integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.13.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
@@ -3644,6 +3663,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/@eslint/core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/espree": { "node_modules/espree": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.137.3", "version": "1.136.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.17.0", "@types/node": "^22.16.5",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -35,7 +35,7 @@
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^59.0.0",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^28.3.1",
"globals": "^16.0.0", "globals": "^16.0.0",
"jose": "^5.6.3", "jose": "^5.6.3",
@@ -54,6 +54,6 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "22.18.0" "node": "22.17.1"
} }
} }
+1 -13
View File
@@ -9,7 +9,7 @@ import {
} from '@immich/sdk'; } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures'; import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, asBearerAuth, baseUrl, shareUrl, utils } from 'src/utils'; import { app, asBearerAuth, shareUrl, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
@@ -78,7 +78,6 @@ describe('/shared-links', () => {
type: SharedLinkType.Album, type: SharedLinkType.Album,
albumId: metadataAlbum.id, albumId: metadataAlbum.id,
showMetadata: true, showMetadata: true,
slug: 'metadata-album',
}), }),
utils.createSharedLink(user1.accessToken, { utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album, type: SharedLinkType.Album,
@@ -139,17 +138,6 @@ describe('/shared-links', () => {
}); });
}); });
describe('GET /s/:slug', () => {
it('should work for slug auth', async () => {
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
});
});
describe('GET /shared-links', () => { describe('GET /shared-links', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).get('/shared-links'); const { status, body } = await request(app).get('/shared-links');
+8 -3
View File
@@ -724,7 +724,6 @@
"create_new_user": "Create new user", "create_new_user": "Create new user",
"create_shared_album_page_share_add_assets": "ADD ASSETS", "create_shared_album_page_share_add_assets": "ADD ASSETS",
"create_shared_album_page_share_select_photos": "Select Photos", "create_shared_album_page_share_select_photos": "Select Photos",
"create_shared_link": "Create shared link",
"create_tag": "Create tag", "create_tag": "Create tag",
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user", "create_user": "Create user",
@@ -1174,7 +1173,6 @@
"latest_version": "Latest Version", "latest_version": "Latest Version",
"latitude": "Latitude", "latitude": "Latitude",
"leave": "Leave", "leave": "Leave",
"leave_album": "Leave album",
"lens_model": "Lens model", "lens_model": "Lens model",
"let_others_respond": "Let others respond", "let_others_respond": "Let others respond",
"level": "Level", "level": "Level",
@@ -1254,7 +1252,7 @@
"manage_your_devices": "Manage your logged-in devices", "manage_your_devices": "Manage your logged-in devices",
"manage_your_oauth_connection": "Manage your OAuth connection", "manage_your_oauth_connection": "Manage your OAuth connection",
"map": "Map", "map": "Map",
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}", "map_assets_in_bounds": "{count, plural, one {# photo} other {# photos}}",
"map_cannot_get_user_location": "Cannot get user's location", "map_cannot_get_user_location": "Cannot get user's location",
"map_location_dialog_yes": "Yes", "map_location_dialog_yes": "Yes",
"map_location_picker_page_use_location": "Use this location", "map_location_picker_page_use_location": "Use this location",
@@ -1262,6 +1260,7 @@
"map_location_service_disabled_title": "Location Service disabled", "map_location_service_disabled_title": "Location Service disabled",
"map_marker_for_images": "Map marker for images taken in {city}, {country}", "map_marker_for_images": "Map marker for images taken in {city}, {country}",
"map_marker_with_image": "Map marker with image", "map_marker_with_image": "Map marker with image",
"map_no_assets_in_bounds": "No photos in this area",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_no_location_permission_title": "Location Permission denied", "map_no_location_permission_title": "Location Permission denied",
"map_settings": "Map settings", "map_settings": "Map settings",
@@ -1789,9 +1788,15 @@
"shared_link_expires_seconds": "Expires in {count} seconds", "shared_link_expires_seconds": "Expires in {count} seconds",
"shared_link_individual_shared": "Individual shared", "shared_link_individual_shared": "Individual shared",
"shared_link_info_chip_metadata": "EXIF", "shared_link_info_chip_metadata": "EXIF",
"shared_link_invalid_password": "Invalid password",
"shared_link_manage_links": "Manage Shared links", "shared_link_manage_links": "Manage Shared links",
"shared_link_options": "Shared link options", "shared_link_options": "Shared link options",
"shared_link_password_description": "Require a password to access this shared link", "shared_link_password_description": "Require a password to access this shared link",
"shared_link_password_dialog_content": "Enter the password for the shared link.",
"shared_link_password_dialog_title": "Shared Link Password",
"shared_link": "Shared link",
"shared_link_upload": "Uploaded to shared link",
"shared_link_download": "Downloaded from shared link",
"shared_links": "Shared links", "shared_links": "Shared links",
"shared_links_description": "Share photos and videos with a link", "shared_links_description": "Share photos and videos with a link",
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}", "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly. // Autogenerated from Pigeon (v25.3.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -29,24 +29,21 @@ open class NativeSyncApiImplBase(context: Context) {
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
) )
const val BUCKET_SELECTION = "(${MediaStore.Files.FileColumns.BUCKET_ID} = ?)" const val BUCKET_SELECTION = "(${MediaStore.Files.FileColumns.BUCKET_ID} = ?)"
val ASSET_PROJECTION = buildList { val ASSET_PROJECTION = arrayOf(
add(MediaStore.MediaColumns._ID) MediaStore.MediaColumns._ID,
add(MediaStore.MediaColumns.DATA) MediaStore.MediaColumns.DATA,
add(MediaStore.MediaColumns.DISPLAY_NAME) MediaStore.MediaColumns.DISPLAY_NAME,
add(MediaStore.MediaColumns.DATE_TAKEN) MediaStore.MediaColumns.DATE_TAKEN,
add(MediaStore.MediaColumns.DATE_ADDED) MediaStore.MediaColumns.DATE_ADDED,
add(MediaStore.MediaColumns.DATE_MODIFIED) MediaStore.MediaColumns.DATE_MODIFIED,
add(MediaStore.Files.FileColumns.MEDIA_TYPE) MediaStore.Files.FileColumns.MEDIA_TYPE,
add(MediaStore.MediaColumns.BUCKET_ID) MediaStore.MediaColumns.BUCKET_ID,
add(MediaStore.MediaColumns.WIDTH) MediaStore.MediaColumns.WIDTH,
add(MediaStore.MediaColumns.HEIGHT) MediaStore.MediaColumns.HEIGHT,
add(MediaStore.MediaColumns.DURATION) MediaStore.MediaColumns.DURATION,
add(MediaStore.MediaColumns.ORIENTATION) MediaStore.MediaColumns.ORIENTATION,
// IS_FAVORITE is only available on Android 11 and above MediaStore.MediaColumns.IS_FAVORITE,
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { )
add(MediaStore.MediaColumns.IS_FAVORITE)
}
}.toTypedArray()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024 const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
} }
@@ -81,7 +78,7 @@ open class NativeSyncApiImplBase(context: Context) {
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION) val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
val orientationColumn = val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION) c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE) val favoriteColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.IS_FAVORITE)
while (c.moveToNext()) { while (c.moveToNext()) {
val id = c.getLong(idColumn).toString() val id = c.getLong(idColumn).toString()
@@ -110,7 +107,7 @@ open class NativeSyncApiImplBase(context: Context) {
else c.getLong(durationColumn) / 1000 else c.getLong(durationColumn) / 1000
val bucketId = c.getString(bucketIdColumn) val bucketId = c.getString(bucketIdColumn)
val orientation = c.getInt(orientationColumn) val orientation = c.getInt(orientationColumn)
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 val isFavorite = c.getInt(favoriteColumn) != 0;
val asset = PlatformAsset( val asset = PlatformAsset(
id, id,
@@ -24,23 +24,14 @@ class ImmichAPI(cfg: ServerConfig) {
val serverURL = prefs.getString("widget_server_url", "") ?: "" val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: "" val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
val customHeadersJSON = prefs.getString("widget_custom_headers", "") ?: ""
if (serverURL.isBlank() || sessionKey.isBlank()) { if (serverURL.isBlank() || sessionKey.isBlank()) {
return null return null
} }
var customHeaders: Map<String, String> = HashMap<String, String>()
if (customHeadersJSON.isNotBlank()) {
val stringMapType = object : TypeToken<Map<String, String>>() {}.type
customHeaders = Gson().fromJson(customHeadersJSON, stringMapType)
}
return ServerConfig( return ServerConfig(
serverURL, serverURL,
sessionKey, sessionKey
customHeaders
) )
} }
} }
@@ -59,19 +50,11 @@ class ImmichAPI(cfg: ServerConfig) {
return URL(urlString.toString()) return URL(urlString.toString())
} }
private fun HttpURLConnection.applyCustomHeaders() {
serverConfig.customHeaders.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) { suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random") val url = buildRequestURL("/search/random")
val connection = (url.openConnection() as HttpURLConnection).apply { val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST" requestMethod = "POST"
setRequestProperty("Content-Type", "application/json") setRequestProperty("Content-Type", "application/json")
applyCustomHeaders()
doOutput = true doOutput = true
} }
@@ -92,7 +75,6 @@ class ImmichAPI(cfg: ServerConfig) {
val url = buildRequestURL("/memories", listOf("for" to iso8601)) val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply { val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
applyCustomHeaders()
} }
val response = connection.inputStream.bufferedReader().readText() val response = connection.inputStream.bufferedReader().readText()
@@ -112,7 +94,6 @@ class ImmichAPI(cfg: ServerConfig) {
val url = buildRequestURL("/albums") val url = buildRequestURL("/albums")
val connection = (url.openConnection() as HttpURLConnection).apply { val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
applyCustomHeaders()
} }
val response = connection.inputStream.bufferedReader().readText() val response = connection.inputStream.bufferedReader().readText()
@@ -55,11 +55,7 @@ data class WidgetEntry (
val deeplink: String? val deeplink: String?
) )
data class ServerConfig( data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
val serverEndpoint: String,
val sessionKey: String,
val customHeaders: Map<String, String>
)
// MARK: Widget State Keys // MARK: Widget State Keys
val kImageUUID = stringPreferencesKey("uuid") val kImageUUID = stringPreferencesKey("uuid")
+2 -2
View File
@@ -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" => 205,
"android.injected.version.name" => "1.137.3", "android.injected.version.name" => "1.136.0",
} }
) )
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')
+9 -9
View File
@@ -649,7 +649,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -793,7 +793,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -823,7 +823,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -857,7 +857,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -900,7 +900,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -940,7 +940,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -979,7 +979,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1023,7 +1023,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1064,7 +1064,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213; CURRENT_PROJECT_VERSION = 210;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
+2 -2
View File
@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.137.2</string> <string>1.135.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -105,7 +105,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>213</string> <string>210</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true /> <true />
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly. // Autogenerated from Pigeon (v25.3.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
import Foundation import Foundation
+1 -23
View File
@@ -104,13 +104,10 @@ struct Album: Codable, Equatable {
// MARK: API // MARK: API
class ImmichAPI { class ImmichAPI {
typealias CustomHeaders = [String:String]
struct ServerConfig { struct ServerConfig {
let serverEndpoint: String let serverEndpoint: String
let sessionKey: String let sessionKey: String
let customHeaders: CustomHeaders
} }
let serverConfig: ServerConfig let serverConfig: ServerConfig
init() async throws { init() async throws {
@@ -126,19 +123,9 @@ class ImmichAPI {
throw WidgetError.noLogin throw WidgetError.noLogin
} }
// custom headers come in the form of KV pairs in JSON
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
var customHeaders: CustomHeaders = [:]
if customHeadersJSON != "",
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
customHeaders = parsedHeaders
}
serverConfig = ServerConfig( serverConfig = ServerConfig(
serverEndpoint: serverURL, serverEndpoint: serverURL,
sessionKey: sessionKey, sessionKey: sessionKey
customHeaders: customHeaders
) )
} }
@@ -169,12 +156,6 @@ class ImmichAPI {
return components?.url return components?.url
} }
func applyCustomHeaders(for request: inout URLRequest) {
for (header, value) in serverConfig.customHeaders {
request.addValue(value, forHTTPHeaderField: header)
}
}
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter) func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws async throws
-> [Asset] -> [Asset]
@@ -193,7 +174,6 @@ class ImmichAPI {
request.httpMethod = "POST" request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(filters) request.httpBody = try JSONEncoder().encode(filters)
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
@@ -216,7 +196,6 @@ class ImmichAPI {
var request = URLRequest(url: searchURL) var request = URLRequest(url: searchURL)
request.httpMethod = "GET" request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
@@ -275,7 +254,6 @@ class ImmichAPI {
var request = URLRequest(url: searchURL) var request = URLRequest(url: searchURL)
request.httpMethod = "GET" request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
+1 -1
View File
@@ -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.136.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
+1 -2
View File
@@ -30,10 +30,9 @@ const int kTimelineAssetLoadBatchSize = 256;
const int kTimelineAssetLoadOppositeSize = 64; const int kTimelineAssetLoadOppositeSize = 64;
// Widget keys // Widget keys
const String appShareGroupId = "group.app.immich.share";
const String kWidgetAuthToken = "widget_auth_token"; const String kWidgetAuthToken = "widget_auth_token";
const String kWidgetServerEndpoint = "widget_server_url"; const String kWidgetServerEndpoint = "widget_server_url";
const String kWidgetCustomHeaders = "widget_custom_headers"; const String appShareGroupId = "group.app.immich.share";
// add widget identifiers here for new widgets // add widget identifiers here for new widgets
// these are used to force a widget refresh // these are used to force a widget refresh
@@ -0,0 +1,21 @@
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SharedRemoteAlbum extends RemoteAlbum {
final List<RemoteAsset> assets;
const SharedRemoteAlbum({
required super.id,
required this.assets,
required super.name,
required super.ownerId,
required super.description,
required super.createdAt,
required super.updatedAt,
super.thumbnailAssetId,
required super.isActivityEnabled,
required super.order,
required super.assetCount,
required super.ownerName,
});
}
+1 -1
View File
@@ -96,7 +96,7 @@ class HashService {
if (hash?.length == 20) { if (hash?.length == 20) {
hashed.add(asset.copyWith(checksum: base64.encode(hash!))); hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
} else { } else {
_log.warning("Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt}"); _log.warning("Failed to hash file for ${asset.id}");
} }
} }
@@ -128,18 +128,6 @@ class RemoteAlbumService {
return _repository.addUsers(albumId, userIds); return _repository.addUsers(albumId, userIds);
} }
Future<void> removeUser(String albumId, {required String userId}) async {
await _albumApiRepository.removeUser(albumId, userId: userId);
return _repository.removeUser(albumId, userId: userId);
}
Future<void> setActivityStatus(String albumId, bool enabled) async {
await _albumApiRepository.setActivityStatus(albumId, enabled);
return _repository.setActivityStatus(albumId, enabled);
}
Future<int> getCount() { Future<int> getCount() {
return _repository.getCount(); return _repository.getCount();
} }
@@ -0,0 +1,34 @@
import 'dart:async';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/domain/models/album/shared_album.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
class RemoteSharedAlbumService {
final DriftAlbumApiRepository _albumApiRepository;
final AssetApiRepository _assetApiRepository;
const RemoteSharedAlbumService(this._albumApiRepository, this._assetApiRepository);
Future<SharedRemoteAlbum?> getSharedAlbum(String albumId) {
return _albumApiRepository.getShared(albumId);
}
Future<int> uploadAssets(String albumId, List<XFile> files) async {
// Start all uploads concurrently
final uploadFutures = files.map((file) => _assetApiRepository.uploadAsset(file)).toList();
// Wait for all uploads to complete
final assetIds = await Future.wait(uploadFutures);
// Filter out null assetIds
final completedUploads = assetIds.whereType<String>().toList();
if (completedUploads.isNotEmpty) {
await _albumApiRepository.addAssets(albumId, completedUploads);
}
return completedUploads.length;
}
}
+1 -40
View File
@@ -1,10 +1,9 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart'; import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility; import 'package:openapi/api.dart' hide AssetVisibility;
class SearchService { class SearchService {
@@ -52,41 +51,3 @@ class SearchService {
return null; return null;
} }
} }
extension on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
);
}
}
extension on AssetTypeEnum {
AssetType toAssetType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}
@@ -139,18 +139,14 @@ class SyncStreamService {
return _syncStreamRepository.updateAlbumUsersV1(data.cast(), debugLabel: 'backfill'); return _syncStreamRepository.updateAlbumUsersV1(data.cast(), debugLabel: 'backfill');
case SyncEntityType.albumUserDeleteV1: case SyncEntityType.albumUserDeleteV1:
return _syncStreamRepository.deleteAlbumUsersV1(data.cast()); return _syncStreamRepository.deleteAlbumUsersV1(data.cast());
case SyncEntityType.albumAssetCreateV1: case SyncEntityType.albumAssetV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album');
case SyncEntityType.albumAssetUpdateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update');
case SyncEntityType.albumAssetBackfillV1: case SyncEntityType.albumAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album backfill');
case SyncEntityType.albumAssetExifCreateV1: case SyncEntityType.albumAssetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create'); return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album');
case SyncEntityType.albumAssetExifUpdateV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif update');
case SyncEntityType.albumAssetExifBackfillV1: case SyncEntityType.albumAssetExifBackfillV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif backfill'); return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album backfill');
case SyncEntityType.albumToAssetV1: case SyncEntityType.albumToAssetV1:
return _syncStreamRepository.updateAlbumToAssetsV1(data.cast()); return _syncStreamRepository.updateAlbumToAssetsV1(data.cast());
case SyncEntityType.albumToAssetBackfillV1: case SyncEntityType.albumToAssetBackfillV1:
+2 -6
View File
@@ -37,7 +37,7 @@ class BackgroundSyncManager {
this.onHashingError, this.onHashingError,
}); });
Future<void> cancel() async { Future<void> cancel() {
final futures = <Future>[]; final futures = <Future>[];
if (_syncTask != null) { if (_syncTask != null) {
@@ -52,11 +52,7 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel(); _syncWebsocketTask?.cancel();
_syncWebsocketTask = null; _syncWebsocketTask = null;
try { return Future.wait(futures);
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
} }
// No need to cancel the task, as it can also be run when the user logs out // No need to cancel the task, as it can also be run when the user logs out
@@ -13,9 +13,6 @@ extension ContextHelper on BuildContext {
// Returns the current height from MediaQuery // Returns the current height from MediaQuery
double get height => MediaQuery.sizeOf(this).height; double get height => MediaQuery.sizeOf(this).height;
// Returns the current size from MediaQuery
Size get sizeData => MediaQuery.sizeOf(this);
// Returns true if the app is running on a mobile device (!tablets) // Returns true if the app is running on a mobile device (!tablets)
bool get isMobile => width < 550; bool get isMobile => width < 550;
@@ -1,10 +0,0 @@
import 'dart:ui';
import 'package:flutter/painting.dart';
extension CodecImageInfoExtension on Codec {
Future<ImageInfo> getImageInfo({double scale = 1.0}) async {
final frame = await getNextFrame();
return ImageInfo(image: frame.image, scale: scale);
}
}
@@ -113,7 +113,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
final query = _db.localAssetEntity.select() final query = _db.localAssetEntity.select()
..where( ..where(
(lae) => (lae) =>
lae.checksum.isNotNull() &
existsQuery( existsQuery(
_db.localAlbumAssetEntity.selectOnly() _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId]) ..addColumns([_db.localAlbumAssetEntity.assetId])
@@ -126,7 +125,9 @@ class DriftBackupRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.selectOnly() _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.checksum]) ..addColumns([_db.remoteAssetEntity.checksum])
..where( ..where(
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId), _db.remoteAssetEntity.checksum.equalsExp(lae.checksum) &
_db.remoteAssetEntity.ownerId.equals(userId) &
lae.checksum.isNotNull(),
), ),
) & ) &
lae.id.isNotInQuery(_getExcludedSubquery()), lae.id.isNotInQuery(_getExcludedSubquery()),
@@ -24,7 +24,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
leftOuterJoin(_db.assetFaceEntity, _db.assetFaceEntity.personId.equalsExp(_db.personEntity.id)), leftOuterJoin(_db.assetFaceEntity, _db.assetFaceEntity.personId.equalsExp(_db.personEntity.id)),
]) ])
..where(_db.personEntity.isHidden.equals(false)) ..where(_db.personEntity.isHidden.equals(false))
..groupBy([_db.personEntity.id], having: _db.assetFaceEntity.id.count().isBiggerOrEqualValue(3)) ..groupBy([_db.personEntity.id])
..orderBy([ ..orderBy([
OrderingTerm(expression: _db.personEntity.name.equals('').not(), mode: OrderingMode.desc), OrderingTerm(expression: _db.personEntity.name.equals('').not(), mode: OrderingMode.desc),
OrderingTerm(expression: _db.assetFaceEntity.id.count(), mode: OrderingMode.desc), OrderingTerm(expression: _db.assetFaceEntity.id.count(), mode: OrderingMode.desc),
@@ -220,22 +220,12 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}); });
} }
Future<void> removeUser(String albumId, {required String userId}) {
return _db.remoteAlbumUserEntity.deleteWhere((row) => row.albumId.equals(albumId) & row.userId.equals(userId));
}
Future<void> deleteAlbum(String albumId) async { Future<void> deleteAlbum(String albumId) async {
return _db.transaction(() async { return _db.transaction(() async {
await _db.remoteAlbumEntity.deleteWhere((table) => table.id.equals(albumId)); await _db.remoteAlbumEntity.deleteWhere((table) => table.id.equals(albumId));
}); });
} }
Future<void> setActivityStatus(String albumId, bool isEnabled) async {
final query = _db.update(_db.remoteAlbumEntity)..where((row) => row.id.equals(albumId));
await query.write(RemoteAlbumEntityCompanion(isActivityEnabled: Value(isEnabled)));
}
Stream<RemoteAlbum?> watchAlbum(String albumId) { Stream<RemoteAlbum?> watchAlbum(String albumId) {
final query = final query =
_db.remoteAlbumEntity.select().join([ _db.remoteAlbumEntity.select().join([
@@ -149,11 +149,9 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.albumUserV1: SyncAlbumUserV1.fromJson, SyncEntityType.albumUserV1: SyncAlbumUserV1.fromJson,
SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson, SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson,
SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson, SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson,
SyncEntityType.albumAssetCreateV1: SyncAssetV1.fromJson, SyncEntityType.albumAssetV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetUpdateV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson, SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetExifCreateV1: SyncAssetExifV1.fromJson, SyncEntityType.albumAssetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumAssetExifUpdateV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson, SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumToAssetV1: SyncAlbumToAssetV1.fromJson, SyncEntityType.albumToAssetV1: SyncAlbumToAssetV1.fromJson,
SyncEntityType.albumToAssetBackfillV1: SyncAlbumToAssetV1.fromJson, SyncEntityType.albumToAssetBackfillV1: SyncAlbumToAssetV1.fromJson,
+2 -1
View File
@@ -23,7 +23,7 @@ import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/backgroundLegacy.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart';
@@ -83,6 +83,7 @@ Future<void> initApp() async {
}; };
PlatformDispatcher.instance.onError = (error, stack) { PlatformDispatcher.instance.onError = (error, stack) {
debugPrint("FlutterError - Catch all: $error \n $stack");
log.severe('PlatformDispatcher - Catch all', error, stack); log.severe('PlatformDispatcher - Catch all', error, stack);
return true; return true;
}; };
@@ -1,3 +1,5 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
enum SharedLinkSource { album, individual } enum SharedLinkSource { album, individual }
@@ -14,6 +16,8 @@ class SharedLink {
final String key; final String key;
final bool showMetadata; final bool showMetadata;
final SharedLinkSource type; final SharedLinkSource type;
final List<RemoteAsset> assets;
final String? albumId;
const SharedLink({ const SharedLink({
required this.id, required this.id,
@@ -27,6 +31,8 @@ class SharedLink {
required this.key, required this.key,
required this.showMetadata, required this.showMetadata,
required this.type, required this.type,
this.assets = const [],
this.albumId,
}); });
SharedLink copyWith({ SharedLink copyWith({
@@ -74,7 +80,9 @@ class SharedLink {
? dto.album?.albumThumbnailAssetId ? dto.album?.albumThumbnailAssetId
: dto.assets.isNotEmpty : dto.assets.isNotEmpty
? dto.assets[0].id ? dto.assets[0].id
: null; : null,
assets = dto.assets.map((asset) => asset.toDto()).toList(),
albumId = dto.album?.id;
@override @override
String toString() => String toString() =>
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.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/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.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';
@@ -13,6 +14,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart'; import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage() @RoutePage()
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget { class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
@@ -65,14 +67,14 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList(); final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList();
final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
// handleSyncAlbumToggle(bool isEnable) async { handleSyncAlbumToggle(bool isEnable) async {
// if (isEnable) { if (isEnable) {
// await ref.read(albumProvider.notifier).refreshRemoteAlbums(); await ref.read(albumProvider.notifier).refreshRemoteAlbums();
// for (final album in selectedBackupAlbums) { for (final album in selectedBackupAlbums) {
// await ref.read(albumProvider.notifier).createSyncAlbum(album.name); await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
// } }
// } }
// } }
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, result) async { onPopInvokedWithResult: (didPop, result) async {
@@ -165,15 +167,16 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
), ),
), ),
// SettingsSwitchListTile( SettingsSwitchListTile(
// valueNotifier: _enableSyncUploadAlbum, valueNotifier: _enableSyncUploadAlbum,
// title: "sync_albums".t(context: context), title: "sync_albums".t(context: context),
// subtitle: "sync_upload_album_setting_subtitle".t(context: context), subtitle: "sync_upload_album_setting_subtitle".t(context: context),
// contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 16),
// titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
// subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary), subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
// onChanged: handleSyncAlbumToggle, onChanged: handleSyncAlbumToggle,
// ), ),
ListTile( ListTile(
title: Text( title: Text(
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}), "albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),
+7 -34
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -139,50 +137,25 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
ref.read(tabProvider.notifier).state = TabEnum.values[index]; ref.read(tabProvider.notifier).state = TabEnum.values[index];
} }
class _BottomNavigationBar extends ConsumerStatefulWidget { class _BottomNavigationBar extends ConsumerWidget {
const _BottomNavigationBar({required this.tabsRouter, required this.destinations}); const _BottomNavigationBar({required this.tabsRouter, required this.destinations});
final List<Widget> destinations; final List<Widget> destinations;
final TabsRouter tabsRouter; final TabsRouter tabsRouter;
@override @override
ConsumerState createState() => _BottomNavigationBarState(); Widget build(BuildContext context, WidgetRef ref) {
}
class _BottomNavigationBarState extends ConsumerState<_BottomNavigationBar> {
bool hideNavigationBar = false;
StreamSubscription? _eventSubscription;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen<MultiSelectToggleEvent>(_onEvent);
}
void _onEvent(MultiSelectToggleEvent event) {
setState(() {
hideNavigationBar = event.isEnabled;
});
}
@override
void dispose() {
_eventSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isScreenLandscape = context.orientation == Orientation.landscape; final isScreenLandscape = context.orientation == Orientation.landscape;
final isMultiselectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
if (isScreenLandscape || hideNavigationBar) { if (isScreenLandscape || isMultiselectEnabled) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return NavigationBar( return NavigationBar(
selectedIndex: widget.tabsRouter.activeIndex, selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) => _onNavigationSelected(widget.tabsRouter, index, ref), onDestinationSelected: (index) => _onNavigationSelected(tabsRouter, index, ref),
destinations: widget.destinations, destinations: destinations,
); );
} }
} }
@@ -264,15 +264,11 @@ class SharedLinkEditPage extends HookConsumerWidget {
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(), expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
); );
ref.invalidate(sharedLinksStateProvider); ref.invalidate(sharedLinksStateProvider);
await ref.read(serverInfoProvider.notifier).getServerConfig();
final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain)); final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
if (serverUrl != null && !serverUrl.endsWith('/')) { if (serverUrl != null && !serverUrl.endsWith('/')) {
serverUrl += '/'; serverUrl += '/';
} }
if (newLink != null && serverUrl != null) { if (newLink != null && serverUrl != null) {
newShareLink.value = "${serverUrl}share/${newLink.key}"; newShareLink.value = "${serverUrl}share/${newLink.key}";
copyLinkToClipboard(); copyLinkToClipboard();
+1 -1
View File
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly. // Autogenerated from Pigeon (v25.3.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
@@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage() @RoutePage()
class MainTimelinePage extends ConsumerWidget { class MainTimelinePage extends ConsumerWidget {
@@ -13,24 +12,21 @@ class MainTimelinePage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
final memoriesEnabled = ref.watch(currentUserProvider.select((user) => user?.memoryEnabled ?? true));
// TODO: the user preferences need to be updated
// from the server to get live hiding/showing of memory lane
return memoryLaneProvider.maybeWhen( return memoryLaneProvider.maybeWhen(
data: (memories) { data: (memories) {
return memories.isEmpty || !memoriesEnabled return memories.isEmpty
? const Timeline() ? const Timeline(showStorageIndicator: true)
: Timeline( : Timeline(
topSliverWidget: SliverToBoxAdapter( topSliverWidget: SliverToBoxAdapter(
key: Key('memory-lane-${memories.first.assets.first.id}'), key: Key('memory-lane-${memories.first.assets.first.id}'),
child: DriftMemoryLane(memories: memories), child: DriftMemoryLane(memories: memories),
), ),
topSliverWidgetHeight: 200, topSliverWidgetHeight: 200,
showStorageIndicator: true,
); );
}, },
orElse: () => const Timeline(), orElse: () => const Timeline(showStorageIndicator: true),
); );
} }
} }
@@ -1,237 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage()
class DriftAlbumOptionsPage extends HookConsumerWidget {
const DriftAlbumOptionsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider);
if (album == null) {
return const SizedBox();
}
final sharedUsersAsync = ref.watch(remoteAlbumSharedUsersProvider(album.id));
final userId = ref.watch(authProvider).userId;
final activityEnabled = useState(album.isActivityEnabled);
final isOwner = album.ownerId == userId;
void showErrorMessage() {
context.pop();
ImmichToast.show(
context: context,
msg: "shared_album_section_people_action_error".t(context: context),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
void leaveAlbum() async {
try {
await ref.read(remoteAlbumProvider.notifier).leaveAlbum(album.id, userId: userId);
context.navigateTo(const DriftAlbumsRoute());
} catch (_) {
showErrorMessage();
}
}
void removeUserFromAlbum(UserDto user) async {
try {
await ref.read(remoteAlbumProvider.notifier).removeUser(album.id, user.id);
ref.invalidate(remoteAlbumSharedUsersProvider(album.id));
} catch (_) {
showErrorMessage();
}
context.pop();
}
Future<void> addUsers() async {
final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: album));
if (newUsers == null || newUsers.isEmpty) {
return;
}
try {
await ref.read(remoteAlbumProvider.notifier).addUsers(album.id, newUsers);
if (newUsers.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "users_added_to_album_count".t(context: context, args: {'count': newUsers.length}),
toastType: ToastType.success,
);
}
ref.invalidate(remoteAlbumSharedUsersProvider(album.id));
} catch (e) {
ImmichToast.show(
context: context,
msg: "Failed to add users to album: ${e.toString()}",
toastType: ToastType.error,
);
}
}
void handleUserClick(UserDto user) {
var actions = [];
if (user.id == userId) {
actions = [
ListTile(
leading: const Icon(Icons.exit_to_app_rounded),
title: const Text("leave_album").t(context: context),
onTap: leaveAlbum,
),
];
}
if (isOwner) {
actions = [
ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text("remove_user").t(context: context),
onTap: () => removeUserFromAlbum(user),
),
];
}
showModalBottomSheet(
backgroundColor: context.colorScheme.surfaceContainer,
isScrollControlled: false,
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(mainAxisSize: MainAxisSize.min, children: [...actions]),
),
);
},
);
}
buildOwnerInfo() {
if (isOwner) {
final owner = ref.watch(currentUserProvider);
return ListTile(
leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(),
title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(owner?.email ?? "", style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
);
} else {
final usersProvider = ref.watch(driftUsersProvider);
return usersProvider.maybeWhen(
data: (users) {
final user = users.firstWhereOrNull((u) => u.id == album.ownerId);
if (user == null) {
return const SizedBox();
}
return ListTile(
leading: UserCircleAvatar(user: user, radius: 22),
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
);
},
orElse: () => const SizedBox(),
);
}
}
buildSharedUsersList() {
return sharedUsersAsync.maybeWhen(
data: (sharedUsers) => ListView.builder(
primary: false,
shrinkWrap: true,
itemCount: sharedUsers.length,
itemBuilder: (context, index) {
final user = sharedUsers[index];
return ListTile(
leading: UserCircleAvatar(user: user, radius: 22),
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
onTap: userId == user.id || isOwner ? () => handleUserClick(user) : null,
);
},
),
orElse: () => const Center(child: CircularProgressIndicator()),
);
}
buildSectionTitle(String text) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(text, style: context.textTheme.bodySmall),
);
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () => context.maybePop(null),
),
centerTitle: true,
title: Text("options".t(context: context)),
),
body: ListView(
children: [
const SizedBox(height: 8),
if (isOwner)
SwitchListTile.adaptive(
value: activityEnabled.value,
onChanged: (bool value) async {
activityEnabled.value = value;
await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value);
},
activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
dense: true,
title: Text(
"comments_and_likes",
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
).t(context: context),
subtitle: Text(
"let_others_respond",
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
).t(context: context),
),
buildSectionTitle("shared_album_section_people_title".t(context: context)),
if (isOwner) ...[
ListTile(
leading: const Icon(Icons.person_add_rounded),
title: Text("invite_people".t(context: context)),
onTap: () async => addUsers(),
),
const Divider(indent: 16),
],
buildOwnerInfo(),
buildSharedUsersList(),
],
),
);
}
}
@@ -28,15 +28,13 @@ class RemoteAlbumPage extends ConsumerStatefulWidget {
} }
class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> { class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
late RemoteAlbum _album;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_album = widget.album;
} }
Future<void> addAssets(BuildContext context) async { Future<void> addAssets(BuildContext context) async {
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id); final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(widget.album.id);
final newAssets = await context.pushRoute<Set<BaseAsset>>( final newAssets = await context.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()), DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
@@ -49,7 +47,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
final added = await ref final added = await ref
.read(remoteAlbumProvider.notifier) .read(remoteAlbumProvider.notifier)
.addAssets( .addAssets(
_album.id, widget.album.id,
newAssets.map((asset) { newAssets.map((asset) {
final remoteAsset = asset as RemoteAsset; final remoteAsset = asset as RemoteAsset;
return remoteAsset.id; return remoteAsset.id;
@@ -66,14 +64,14 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
} }
Future<void> addUsers(BuildContext context) async { Future<void> addUsers(BuildContext context) async {
final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: _album)); final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: widget.album));
if (newUsers == null || newUsers.isEmpty) { if (newUsers == null || newUsers.isEmpty) {
return; return;
} }
try { try {
await ref.read(remoteAlbumProvider.notifier).addUsers(_album.id, newUsers); await ref.read(remoteAlbumProvider.notifier).addUsers(widget.album.id, newUsers);
if (newUsers.isNotEmpty) { if (newUsers.isNotEmpty) {
ImmichToast.show( ImmichToast.show(
@@ -83,7 +81,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
); );
} }
ref.invalidate(remoteAlbumSharedUsersProvider(_album.id)); ref.invalidate(remoteAlbumSharedUsersProvider(widget.album.id));
} catch (e) { } catch (e) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
@@ -94,7 +92,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
} }
Future<void> toggleAlbumOrder() async { Future<void> toggleAlbumOrder() async {
await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder(_album.id); await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder(widget.album.id);
ref.invalidate(timelineServiceProvider); ref.invalidate(timelineServiceProvider);
} }
@@ -108,7 +106,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('album_delete_confirmation'.t(context: context, args: {'album': _album.name})), Text('album_delete_confirmation'.t(context: context, args: {'album': widget.album.name})),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('album_delete_confirmation_description'.t(context: context)), Text('album_delete_confirmation_description'.t(context: context)),
], ],
@@ -130,7 +128,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
if (confirmed == true) { if (confirmed == true) {
try { try {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(_album.id); await ref.read(remoteAlbumProvider.notifier).deleteAlbum(widget.album.id);
ImmichToast.show( ImmichToast.show(
context: context, context: context,
@@ -153,20 +151,17 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
final result = await showDialog<_EditAlbumData?>( final result = await showDialog<_EditAlbumData?>(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
builder: (context) => _EditAlbumDialog(album: _album), builder: (context) => _EditAlbumDialog(album: widget.album),
); );
if (result != null && context.mounted) { if (result != null && context.mounted) {
setState(() {
_album = _album.copyWith(name: result.name, description: result.description ?? '');
});
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
} }
} }
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 == widget.album.ownerId : false;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -200,14 +195,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
context.pop(); context.pop();
await showEditTitleAndDescription(context); await showEditTitleAndDescription(context);
}, },
onCreateSharedLink: () async {
context.pop();
context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
},
onShowOptions: () {
context.pop();
context.pushRoute(const DriftAlbumOptionsRoute());
},
); );
}, },
); );
@@ -218,7 +205,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id); final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: widget.album.id);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
@@ -230,7 +217,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
onToggleAlbumOrder: () => toggleAlbumOrder(), onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context), onEditTitle: () => showEditTitleAndDescription(context),
), ),
bottomSheet: RemoteAlbumBottomSheet(album: _album), bottomSheet: RemoteAlbumBottomSheet(album: widget.album),
), ),
); );
} }
@@ -28,6 +28,7 @@ class DriftTrashPage extends StatelessWidget {
}), }),
], ],
child: Timeline( child: Timeline(
showStorageIndicator: true,
appBar: SliverAppBar( appBar: SliverAppBar(
title: Text('trash'.t(context: context)), title: Text('trash'.t(context: context)),
floating: true, floating: true,
@@ -26,6 +26,7 @@ class LocalTimelinePage extends StatelessWidget {
child: Timeline( child: Timeline(
appBar: MesmerizingSliverAppBar(title: album.name), appBar: MesmerizingSliverAppBar(title: album.name),
bottomSheet: const LocalAlbumBottomSheet(), bottomSheet: const LocalAlbumBottomSheet(),
showStorageIndicator: true,
), ),
); );
} }
@@ -0,0 +1,218 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/domain/models/album/shared_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/remote_shared_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/shared_link_password_dialog.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart';
@RoutePage()
class RemoteSharedLinkPage extends ConsumerStatefulWidget {
final String shareKey;
final String endpoint;
const RemoteSharedLinkPage({super.key, required this.shareKey, required this.endpoint});
@override
ConsumerState<RemoteSharedLinkPage> createState() => _RemoteSharedLinkPageState();
}
class _RemoteSharedLinkPageState extends ConsumerState<RemoteSharedLinkPage> {
late final ApiService _apiService;
SharedLink? sharedLink;
List<RemoteAsset>? assets;
SharedRemoteAlbum? sharedAlbum;
late final RemoteSharedAlbumService sharedAlbumService;
@override
void initState() {
super.initState();
String endpoint = widget.endpoint;
if (!endpoint.endsWith('/api')) {
endpoint += '/api';
}
ImageUrlBuilder.setHost(endpoint);
ImageUrlBuilder.setParameter('key', widget.shareKey);
_apiService = ApiService.shared(endpoint, widget.shareKey);
final assetApiRepository = AssetApiRepository(
_apiService.assetsApi,
_apiService.searchApi,
_apiService.stacksApi,
_apiService.trashApi,
);
final driftApiRepository = DriftAlbumApiRepository(_apiService.albumsApi);
sharedAlbumService = RemoteSharedAlbumService(driftApiRepository, assetApiRepository);
retrieveSharedLink();
}
@override
void dispose() {
ImageUrlBuilder.clear();
super.dispose();
}
Future<void> retrieveSharedLink() async {
try {
sharedLink = await SharedLinkService(_apiService).getMySharedLink();
} on ApiException catch (error, _) {
if (error.code == 401 && error.message != null && error.message!.contains("Invalid password")) {
final password = await showDialog<String>(
context: context,
builder: (context) => const SharedLinkPasswordDialog(),
);
if (password == null) {
context.pop();
}
try {
sharedLink = await SharedLinkService(_apiService).getMySharedLink(password: password);
} catch (e) {
ImmichToast.show(
context: context,
msg: "errors.shared_link_invalid_password".t(context: context),
toastType: ToastType.error,
);
context.pop();
return;
}
}
}
if (sharedLink == null) {
ImmichToast.show(
context: context,
msg: "errors.unable_to_get_shared_link".t(context: context),
toastType: ToastType.error,
);
context.pop();
return;
}
_refreshAssets();
setState(() {});
}
Future<List<RemoteAsset>> retrieveSharedAlbumAssets() async {
try {
sharedAlbum = await sharedAlbumService.getSharedAlbum(sharedLink!.albumId!);
return sharedAlbum?.assets ?? [];
} catch (e) {
ImmichToast.show(
context: context,
msg: "errors.failed_to_load_assets".t(context: context),
toastType: ToastType.error,
);
return [];
}
}
Future<void> _refreshAssets() async {
// Retrieve assets from the shared link
switch (sharedLink!.type) {
case SharedLinkSource.album:
assets = await retrieveSharedAlbumAssets();
break;
case SharedLinkSource.individual:
assets = sharedLink!.assets;
if (!(sharedLink!.allowUpload)) {
context.replaceRoute(
AssetViewerRoute(
initialIndex: 0,
timelineService: ref.read(timelineFactoryProvider).fromAssets(sharedLink!.assets),
),
);
}
break;
}
setState(() {});
}
@override
Widget build(BuildContext context) {
Future<void> addAssets() async {
final List<XFile> uploadAssets = await ImagePicker().pickMultipleMedia();
if (uploadAssets.isEmpty) {
return;
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text("uploading".t(context: context)),
content: const SizedBox(height: 48, child: Center(child: CircularProgressIndicator())),
),
);
await sharedAlbumService.uploadAssets(sharedAlbum!.id, uploadAssets);
sharedAlbum = await sharedAlbumService.getSharedAlbum(sharedAlbum!.id);
_refreshAssets();
// close the dialog
context.pop();
}
if (assets == null) {
return const Center(child: CircularProgressIndicator());
}
return ProviderScope(
key: ValueKey(assets?.length),
overrides: [
apiServiceProvider.overrideWith((ref) => _apiService),
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets!);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
topSliverWidgetHeight: 0,
topSliverWidget: const SliverToBoxAdapter(child: SizedBox.shrink()),
groupBy: GroupAssetsBy.none,
bottomSheet: null,
appBar: SliverToBoxAdapter(
child: AppBar(
title: Text(sharedAlbum?.name ?? "shared_link".t(context: context)),
actions: [
if (sharedLink!.allowUpload)
IconButton(
icon: const Icon(Icons.cloud_upload),
onPressed: () => addAssets(),
tooltip: "shared_link_upload".t(context: context),
),
],
),
),
),
);
}
}
@@ -13,7 +13,6 @@ 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/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
@@ -628,12 +627,7 @@ class _SearchResultGrid extends ConsumerWidget {
return timelineService; return timelineService;
}), }),
], ],
child: Timeline( child: Timeline(key: ValueKey(searchResult.totalAssets), appBar: null, groupBy: GroupAssetsBy.none),
key: ValueKey(searchResult.totalAssets),
groupBy: GroupAssetsBy.none,
appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
),
), ),
), ),
); );
@@ -46,7 +46,6 @@ class DownloadActionButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton( return BaseActionButton(
iconData: Icons.download, iconData: Icons.download,
maxWidth: 95,
label: "download".t(context: context), label: "download".t(context: context),
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
@@ -45,7 +45,7 @@ class MoveToLockFolderActionButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton( return BaseActionButton(
maxWidth: 115.0, maxWidth: 100.0,
iconData: Icons.lock_outline_rounded, iconData: Icons.lock_outline_rounded,
label: "move_to_locked_folder".t(context: context), label: "move_to_locked_folder".t(context: context),
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
@@ -43,7 +43,6 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
iconData: Icons.remove_circle_outline, iconData: Icons.remove_circle_outline,
label: "remove_from_album".t(context: context), label: "remove_from_album".t(context: context),
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
maxWidth: 100,
); );
} }
} }
@@ -14,7 +14,6 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@@ -27,9 +26,8 @@ typedef AlbumSelectorCallback = void Function(RemoteAlbum album);
class AlbumSelector extends ConsumerStatefulWidget { class AlbumSelector extends ConsumerStatefulWidget {
final AlbumSelectorCallback onAlbumSelected; final AlbumSelectorCallback onAlbumSelected;
final Function? onKeyboardExpanded;
const AlbumSelector({super.key, required this.onAlbumSelected, this.onKeyboardExpanded}); const AlbumSelector({super.key, required this.onAlbumSelected});
@override @override
ConsumerState<AlbumSelector> createState() => _AlbumSelectorState(); ConsumerState<AlbumSelector> createState() => _AlbumSelectorState();
@@ -53,12 +51,6 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
searchController.addListener(() { searchController.addListener(() {
onSearch(searchController.text, filterMode); onSearch(searchController.text, filterMode);
}); });
searchFocusNode.addListener(() {
if (searchFocusNode.hasFocus) {
widget.onKeyboardExpanded?.call();
}
});
} }
void onSearch(String searchTerm, QuickFilterMode sortMode) { void onSearch(String searchTerm, QuickFilterMode sortMode) {
@@ -586,8 +578,6 @@ class AddToAlbumHeader extends ConsumerWidget {
return; return;
} }
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(newAlbum);
ref.read(multiSelectProvider.notifier).reset();
context.pushRoute(RemoteAlbumRoute(album: newAlbum)); context.pushRoute(RemoteAlbumRoute(album: newAlbum));
} }
@@ -147,7 +147,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Precache both thumbnail and full image for smooth transitions // Precache both thumbnail and full image for smooth transitions
unawaited( unawaited(
Future.wait([ Future.wait([
precacheImage(getThumbnailImageProvider(asset: asset), context, onError: (_, __) {}), precacheImage(
getThumbnailImageProvider(asset: asset, size: screenSize),
context,
onError: (_, __) {},
),
precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}), precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}),
]), ]),
); );
@@ -478,7 +482,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
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: asset, fit: BoxFit.contain, size: Size(ctx.width, ctx.height)),
); );
} }
@@ -509,7 +513,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
final size = ctx.sizeData; final size = Size(ctx.width, ctx.height);
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
key: ValueKey(asset.heroTag), key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size), imageProvider: getFullImageProvider(asset, size: size),
@@ -525,10 +529,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onTapDown: _onTapDown, onTapDown: _onTapDown,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => Container( errorBuilder: (_, __, ___) => Container(
width: size.width, width: ctx.width,
height: size.height, height: ctx.height,
color: backgroundColor, color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain), child: Thumbnail(asset: asset, fit: BoxFit.contain, size: size),
), ),
); );
} }
@@ -558,7 +562,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
asset: asset, asset: asset,
image: Image( image: Image(
key: ValueKey(asset), key: ValueKey(asset),
image: getFullImageProvider(asset, size: ctx.sizeData), image: getFullImageProvider(asset, size: Size(ctx.width, ctx.height)),
fit: BoxFit.contain, fit: BoxFit.contain,
height: ctx.height, height: ctx.height,
width: ctx.width, width: ctx.width,
@@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
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/download_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/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_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/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/trash_action_button.widget.dart';
@@ -22,7 +21,6 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/she
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';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/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/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
@@ -44,8 +42,8 @@ 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 isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
@@ -63,7 +61,6 @@ class AssetDetailBottomSheet extends ConsumerWidget {
const DeleteLocalActionButton(source: ActionSource.viewer), const DeleteLocalActionButton(source: ActionSource.viewer),
const UploadActionButton(source: ActionSource.timeline), const UploadActionButton(source: ActionSource.timeline),
], ],
if (currentAlbum != null) RemoveFromAlbumActionButton(albumId: currentAlbum.id, source: ActionSource.viewer),
]; ];
final lockedViewActions = <Widget>[]; final lockedViewActions = <Widget>[];
@@ -22,13 +22,13 @@ class BaseBottomSheet extends ConsumerStatefulWidget {
this.slivers, this.slivers,
this.controller, this.controller,
this.initialChildSize = 0.35, this.initialChildSize = 0.35,
double? minChildSize, this.minChildSize = 0.15,
this.maxChildSize = 0.65, this.maxChildSize = 0.65,
this.expand = true, this.expand = true,
this.shouldCloseOnMinExtent = true, this.shouldCloseOnMinExtent = true,
this.resizeOnScroll = true, this.resizeOnScroll = true,
this.backgroundColor, this.backgroundColor,
}) : minChildSize = minChildSize ?? 0.15; });
@override @override
ConsumerState<BaseBottomSheet> createState() => _BaseDraggableScrollableSheetState(); ConsumerState<BaseBottomSheet> createState() => _BaseDraggableScrollableSheetState();
@@ -6,8 +6,8 @@ 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/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_permanent_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/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
@@ -25,30 +25,11 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class GeneralBottomSheet extends ConsumerStatefulWidget { class GeneralBottomSheet extends ConsumerWidget {
final double? minChildSize; const GeneralBottomSheet({super.key});
const GeneralBottomSheet({super.key, this.minChildSize});
@override @override
ConsumerState<GeneralBottomSheet> createState() => _GeneralBottomSheetState(); Widget build(BuildContext context, WidgetRef ref) {
}
class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
late DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider); final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
@@ -77,14 +58,8 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
} }
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet( return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.45, initialChildSize: 0.45,
minChildSize: widget.minChildSize,
maxChildSize: 0.85, maxChildSize: 0.85,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
actions: [ actions: [
@@ -94,21 +69,26 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline), const DownloadActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline), : const DeletePermanentActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline), const DeleteActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
],
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
], ],
slivers: [ slivers: [
const AddToAlbumHeader(), const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand), AlbumSelector(onAlbumSelected: addAssetsToAlbum),
], ],
); );
} }
@@ -4,14 +4,13 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) { ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
// Create new provider and cache it // Create new provider and cache it
final ImageProvider provider; final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) { if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, type: asset.type, updatedAt: asset.updatedAt); provider = LocalFullImageProvider(id: id, name: asset.name, size: size, type: asset.type);
} else { } else {
final String assetId; final String assetId;
if (asset is LocalAsset && asset.hasRemote) { if (asset is LocalAsset && asset.hasRemote) {
@@ -27,7 +26,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider; return provider;
} }
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) { ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = const Size.square(256)}) {
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided'); assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
if (remoteId != null) { if (remoteId != null) {
@@ -36,7 +35,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
if (_shouldUseLocalAsset(asset!)) { if (_shouldUseLocalAsset(asset!)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, size: size); return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, name: asset.name, size: size);
} }
final String assetId; final String assetId;
@@ -53,26 +52,3 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
bool _shouldUseLocalAsset(BaseAsset asset) => bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
ImageInfo? getCachedImage(ImageProvider key) {
ImageInfo? thumbnail;
final ImageStreamCompleter? stream = PaintingBinding.instance.imageCache.putIfAbsent(
key,
() => throw Exception(), // don't bother loading if it isn't cached
onError: (_, __) {},
);
if (stream != null) {
void listener(ImageInfo info, bool synchronousCall) {
thumbnail = info;
}
try {
stream.addListener(ImageStreamListener(listener));
} finally {
stream.removeListener(ImageStreamListener(listener));
}
}
return thumbnail;
}
@@ -2,17 +2,15 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.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/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.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/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';
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart'; import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
@@ -24,12 +22,14 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final String id; final String id;
final DateTime updatedAt; final DateTime updatedAt;
final String name;
final Size size; final Size size;
const LocalThumbProvider({ const LocalThumbProvider({
required this.id, required this.id,
required this.updatedAt, required this.updatedAt,
this.size = kThumbnailResolution, required this.name,
this.size = const Size.square(kTimelineFixedTileExtent),
this.cacheManager, this.cacheManager,
}); });
@@ -45,8 +45,10 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
codec: _codec(key, cache, decode), codec: _codec(key, cache, decode),
scale: 1.0, scale: 1.0,
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt), DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
DiagnosticsProperty<String>('Name', key.name),
DiagnosticsProperty<Size>('Size', key.size), DiagnosticsProperty<Size>('Size', key.size),
], ],
); );
@@ -66,7 +68,7 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size); final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
if (thumbnailBytes == null) { if (thumbnailBytes == null) {
PaintingBinding.instance.imageCache.evict(key); PaintingBinding.instance.imageCache.evict(key);
throw StateError("Loading thumb for local photo ${key.id} failed"); throw StateError("Loading thumb for local photo ${key.name} failed");
} }
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes); final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
@@ -92,11 +94,11 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final StorageRepository _storageRepository = const StorageRepository(); final StorageRepository _storageRepository = const StorageRepository();
final String id; final String id;
final String name;
final Size size; final Size size;
final AssetType type; final AssetType type;
final DateTime updatedAt; // temporary, only exists to fetch cached thumbnail until local disk cache is removed
const LocalFullImageProvider({required this.id, required this.size, required this.type, required this.updatedAt}); const LocalFullImageProvider({required this.id, required this.name, required this.size, required this.type});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -105,45 +107,52 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
@override @override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( return MultiImageStreamCompleter(
_codec(key, decode), codec: _codec(key, decode),
initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)), scale: 1.0,
informationCollector: () => <DiagnosticsNode>[ informationCollector: () sync* {
DiagnosticsProperty<String>('Id', key.id), yield ErrorDescription(name);
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt), },
DiagnosticsProperty<Size>('Size', key.size),
],
); );
} }
// 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<Codec> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
try { try {
return switch (key.type) { switch (key.type) {
AssetType.image => _decodeProgressive(key, decode), case AssetType.image:
AssetType.video => _getThumbnailCodec(key, decode), yield* _decodeProgressive(key, decode);
_ => throw StateError('Unsupported asset type ${key.type}'), break;
}; case AssetType.video:
final codec = await _getThumbnailCodec(key, decode);
if (codec == null) {
throw StateError("Failed to load preview for ${key.name}");
}
yield codec;
break;
case AssetType.other:
case AssetType.audio:
throw StateError('Unsupported asset type ${key.type}');
}
} 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.name}', error, stack);
throw const ImageLoadingException('Could not load image from local storage'); throw const ImageLoadingException('Could not load image from local storage');
} }
} }
Stream<ImageInfo> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { Future<Codec?> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async {
final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size); final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
if (thumbBytes == null) { if (thumbBytes == null) {
throw StateError("Failed to load preview for ${key.id}"); return null;
} }
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes); final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer); return decode(buffer);
yield await codec.getImageInfo();
} }
Stream<ImageInfo> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* { Stream<Codec> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
final file = await _storageRepository.getFileForAsset(key.id); final file = await _storageRepository.getFileForAsset(key.id);
if (file == null) { if (file == null) {
throw StateError("Opening file for asset ${key.id} failed"); throw StateError("Opening file for asset ${key.name} failed");
} }
final fileSize = await file.length(); final fileSize = await file.length();
@@ -162,8 +171,7 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size); final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (mediumThumb != null) { if (mediumThumb != null) {
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb); final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
final codec = await decode(mediumBuffer); yield await decode(mediumBuffer);
yield await codec.getImageInfo();
} }
} catch (_) {} } catch (_) {}
} }
@@ -179,26 +187,24 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size); final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (highThumb != null) { if (highThumb != null) {
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb); final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
final codec = await decode(highBuffer); yield await decode(highBuffer);
yield await codec.getImageInfo();
} }
return; return;
} }
final buffer = await ImmutableBuffer.fromFilePath(file.path); final buffer = await ImmutableBuffer.fromFilePath(file.path);
final codec = await decode(buffer); yield await decode(buffer);
yield await codec.getImageInfo();
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalFullImageProvider) { if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && type == other.type; return id == other.id && size == other.size && type == other.type && name == other.name;
} }
return false; return false;
} }
@override @override
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode; int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode ^ name.hashCode;
} }
@@ -1,67 +0,0 @@
// The below code is adapted from cached_network_image package's
// MultiImageStreamCompleter to better suit one-frame image loading.
// In particular, it allows providing an initial image to emit synchronously.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
/// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
ImageInfo? _initialImage;
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
/// should be the primary images to display (typically asynchronously as they load).
/// The [initialImage] is an optional image that will be emitted synchronously
/// until the first stream image is completed, useful as a thumbnail or placeholder.
OneFramePlaceholderImageStreamCompleter(
Stream<ImageInfo> images, {
ImageInfo? initialImage,
InformationCollector? informationCollector,
}) {
_initialImage = initialImage;
images.listen(
_onImage,
onError: (Object error, StackTrace stack) {
reportError(
context: ErrorDescription('resolving a single-frame image stream'),
exception: error,
stack: stack,
informationCollector: informationCollector,
silent: true,
);
},
);
}
void _onImage(ImageInfo image) {
setImage(image);
_initialImage?.dispose();
_initialImage = null;
}
@override
void addListener(ImageStreamListener listener) {
final initialImage = _initialImage;
if (initialImage != null) {
try {
listener.onImage(initialImage.clone(), true);
} catch (exception, stack) {
reportError(
context: ErrorDescription('by a synchronously-called image listener'),
exception: exception,
stack: stack,
);
}
}
super.addListener(listener);
}
@override
void onDisposed() {
_initialImage?.dispose();
_initialImage = null;
super.onDisposed();
}
}
@@ -1,14 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/extensions/codec_extensions.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/providers/image/cache/image_loader.dart'; import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -83,28 +81,36 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
@override @override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager(); final cache = cacheManager ?? RemoteImageCacheManager();
return OneFramePlaceholderImageStreamCompleter( final chunkEvents = StreamController<ImageChunkEvent>();
_codec(key, cache, decode), return MultiImageStreamCompleter(
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)), codec: _codec(key, cache, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
); );
} }
Stream<ImageInfo> _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* { Stream<Codec> _codec(
final codec = await ImageLoader.loadImageFromCache( RemoteFullImageProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async* {
yield await ImageLoader.loadImageFromCache(
getPreviewUrlForRemoteId(key.assetId), getPreviewUrlForRemoteId(key.assetId),
cache: cache, cache: cache,
decode: decode, decode: decode,
chunkEvents: chunkController,
); );
yield await codec.getImageInfo();
if (AppSetting.get(Setting.loadOriginal)) { if (AppSetting.get(Setting.loadOriginal)) {
final codec = await ImageLoader.loadImageFromCache( yield await ImageLoader.loadImageFromCache(
getOriginalUrlForRemoteId(key.assetId), getOriginalUrlForRemoteId(key.assetId),
cache: cache, cache: cache,
decode: decode, decode: decode,
chunkEvents: chunkController,
); );
yield await codec.getImageInfo();
} }
await chunkController.close();
} }
@override @override
@@ -19,7 +19,7 @@ class Thumbnail extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId); final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size);
return OctoImage.fromSet( return OctoImage.fromSet(
image: provider, image: provider,
@@ -2,12 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class ThumbnailTile extends ConsumerWidget { class ThumbnailTile extends ConsumerWidget {
@@ -15,7 +13,7 @@ class ThumbnailTile extends ConsumerWidget {
this.asset, { this.asset, {
this.size = const Size.square(256), this.size = const Size.square(256),
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.showStorageIndicator, this.showStorageIndicator = true,
this.lockSelection = false, this.lockSelection = false,
this.heroOffset, this.heroOffset,
super.key, super.key,
@@ -24,7 +22,7 @@ class ThumbnailTile extends ConsumerWidget {
final BaseAsset asset; final BaseAsset asset;
final Size size; final Size size;
final BoxFit fit; final BoxFit fit;
final bool? showStorageIndicator; final bool showStorageIndicator;
final bool lockSelection; final bool lockSelection;
final int? heroOffset; final int? heroOffset;
@@ -54,9 +52,6 @@ class ThumbnailTile extends ConsumerWidget {
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null; final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final bool storageIndicator =
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
return Stack( return Stack(
children: [ children: [
AnimatedContainer( AnimatedContainer(
@@ -91,7 +86,7 @@ class ThumbnailTile extends ConsumerWidget {
child: _VideoIndicator(asset.duration), child: _VideoIndicator(asset.duration),
), ),
), ),
if (storageIndicator) if (showStorageIndicator)
switch (asset.storage) { switch (asset.storage) {
AssetState.local => const Align( AssetState.local => const Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
@@ -13,7 +13,6 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
this.onCreateSharedLink, this.onCreateSharedLink,
this.onToggleAlbumOrder, this.onToggleAlbumOrder,
this.onEditAlbum, this.onEditAlbum,
this.onShowOptions,
}); });
final VoidCallback? onAddPhotos; final VoidCallback? onAddPhotos;
@@ -23,7 +22,6 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
final VoidCallback? onCreateSharedLink; final VoidCallback? onCreateSharedLink;
final VoidCallback? onToggleAlbumOrder; final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum; final VoidCallback? onEditAlbum;
final VoidCallback? onShowOptions;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -71,12 +69,6 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
title: Text('create_shared_link'.t(context: context), style: textStyle), title: Text('create_shared_link'.t(context: context), style: textStyle),
onTap: onCreateSharedLink, onTap: onCreateSharedLink,
), ),
if (onShowOptions != null)
ListTile(
leading: const Icon(Icons.settings),
title: Text('options'.t(context: context), style: textStyle),
onTap: onShowOptions,
),
if (onDeleteAlbum != null) ...[ if (onDeleteAlbum != null) ...[
const Divider(indent: 16, endIndent: 16), const Divider(indent: 16, endIndent: 16),
ListTile( ListTile(
@@ -1,8 +1,5 @@
import 'dart:ui';
const double kTimelineHeaderExtent = 80.0; const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256); const double kTimelineFixedTileExtent = 256;
const Size kThumbnailResolution = kTimelineFixedTileExtent;
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;
@@ -1,7 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
@@ -11,7 +11,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -126,14 +125,10 @@ class _FixedSegmentRow extends ConsumerWidget {
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
children: [ children: [
for (int i = 0; i < assets.length; i++) for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper( _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i, assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
), ),
], ],
); );
@@ -21,7 +21,7 @@ abstract class SegmentBuilder {
static Widget buildPlaceholder( static Widget buildPlaceholder(
BuildContext context, BuildContext context,
int count, { int count, {
Size size = kTimelineFixedTileExtent, Size size = const Size.square(kTimelineFixedTileExtent),
double spacing = kTimelineSpacing, double spacing = kTimelineSpacing,
}) => RepaintBoundary( }) => RepaintBoundary(
child: FixedTimelineRow( child: FixedTimelineRow(
@@ -14,7 +14,7 @@ class TimelineArgs {
final double maxHeight; final double maxHeight;
final double spacing; final double spacing;
final int columnCount; final int columnCount;
final bool? showStorageIndicator; final bool showStorageIndicator;
final bool withStack; final bool withStack;
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
@@ -23,7 +23,7 @@ class TimelineArgs {
required this.maxHeight, required this.maxHeight,
this.spacing = kTimelineSpacing, this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount, this.columnCount = kTimelineColumnCount,
this.showStorageIndicator, this.showStorageIndicator = false,
this.withStack = false, this.withStack = false,
this.groupBy, this.groupBy,
}); });
@@ -1,14 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
@@ -18,7 +15,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -31,7 +27,7 @@ class Timeline extends StatelessWidget {
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.showStorageIndicator, this.showStorageIndicator = false,
this.withStack = false, this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(), this.bottomSheet = const GeneralBottomSheet(),
@@ -40,7 +36,7 @@ class Timeline extends StatelessWidget {
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final bool? showStorageIndicator; final bool showStorageIndicator;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withStack; final bool withStack;
@@ -92,31 +88,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
// Drag selection state
bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
ScrollPhysics? _scrollPhysics;
int _perRow = 4;
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
setState(() {
_perRow = currentTilesPerRow;
_scaleFactor = 7.0 - _perRow;
_baseScaleFactor = _scaleFactor;
});
});
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
} }
void _onEvent(Event event) { void _onEvent(Event event) {
@@ -132,10 +107,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} }
} }
void _onMultiSelectionToggled(_, bool isEnabled) {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
@override @override
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
@@ -179,71 +150,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}); });
} }
// Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorIndex = index;
_dragging = true;
});
}
void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() {
_dragging = false;
_draggedAssets.clear();
});
// Reset the scrolling state after a small delay to allow bottom sheet to expand again
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
ref.read(timelineStateProvider.notifier).setScrolling(false);
}
});
}
void _dragScroll(ScrollDirection direction) {
_scrollController.animateTo(
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
duration: const Duration(milliseconds: 125),
curve: Curves.easeOut,
);
}
void _handleDragAssetEnter(TimelineAssetIndex index) {
if (_dragAnchorIndex == null || !_dragging) return;
final timelineService = ref.read(timelineServiceProvider);
final dragAnchorIndex = _dragAnchorIndex!;
// Calculate the range of assets to select
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
final count = endIndex - startIndex + 1;
// Load the assets in the range
if (timelineService.hasRange(startIndex, count)) {
final selectedAssets = timelineService.getAssets(startIndex, count);
// Clear previous drag selection and add new range
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
for (final asset in _draggedAssets) {
multiSelectNotifier.deselectAsset(asset);
}
_draggedAssets.clear();
for (final asset in selectedAssets) {
multiSelectNotifier.selectAsset(asset);
_draggedAssets.add(asset);
}
}
}
@override @override
Widget build(BuildContext _) { Widget build(BuildContext _) {
final asyncSegments = ref.watch(timelineSegmentProvider); final asyncSegments = ref.watch(timelineSegmentProvider);
@@ -271,83 +177,43 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
return PrimaryScrollController( return PrimaryScrollController(
controller: _scrollController, controller: _scrollController,
child: RawGestureDetector( child: Stack(
gestures: { children: [
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>( Scrubber(
() => CustomScaleGestureRecognizer(), layoutSegments: segments,
(CustomScaleGestureRecognizer scale) { timelineHeight: maxHeight,
scale.onStart = (details) { topPadding: topPadding,
_baseScaleFactor = _scaleFactor; bottomPadding: bottomPadding,
}; monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
scale.onUpdate = (details) { primary: true,
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0); cacheExtent: maxHeight * 2,
final newPerRow = 7 - newScaleFactor.toInt(); slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (newPerRow != _perRow) { if (widget.topSliverWidget != null) widget.topSliverWidget!,
setState(() { _SliverSegmentedList(
_scaleFactor = newScaleFactor; segments: segments,
_perRow = newPerRow; delegate: SliverChildBuilderDelegate(
}); (ctx, index) {
if (index >= childCount) return null;
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); final segment = segments.findByIndex(index);
} return segment?.builder(ctx, index) ?? const SizedBox.shrink();
}; },
}, childCount: childCount,
), addAutomaticKeepAlives: false,
}, // We add repaint boundary around tiles, so skip the auto boundaries
child: TimelineDragRegion( addRepaintBoundaries: false,
onStart: _setDragStartIndex, ),
onAssetEnter: _handleDragAssetEnter,
onEnd: _stopDrag,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
children: [
Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode)
const SelectionSliverAppBar()
else if (widget.appBar != null)
widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
), ),
), const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!,
], ],
], ),
), ),
), if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
), ),
); );
}, },
@@ -577,11 +443,3 @@ class _MultiSelectStatusButton extends ConsumerWidget {
); );
} }
} }
/// accepts a gesture even though it should reject it (because child won)
class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
@@ -1,212 +0,0 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class TimelineDragRegion extends StatefulWidget {
final Widget child;
final void Function(TimelineAssetIndex valueKey)? onStart;
final void Function(TimelineAssetIndex valueKey)? onAssetEnter;
final void Function()? onEnd;
final void Function()? onScrollStart;
final void Function(ScrollDirection direction)? onScroll;
const TimelineDragRegion({
super.key,
required this.child,
this.onStart,
this.onAssetEnter,
this.onEnd,
this.onScrollStart,
this.onScroll,
});
@override
State createState() => _TimelineDragRegionState();
}
class _TimelineDragRegionState extends State<TimelineDragRegion> {
late TimelineAssetIndex? assetUnderPointer;
late TimelineAssetIndex? anchorAsset;
// Scroll related state
static const double scrollOffset = 0.10;
double? topScrollOffset;
double? bottomScrollOffset;
Timer? scrollTimer;
late bool scrollNotified;
@override
void initState() {
super.initState();
assetUnderPointer = null;
anchorAsset = null;
scrollNotified = false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
topScrollOffset = null;
bottomScrollOffset = null;
}
@override
void dispose() {
scrollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomLongPressGestureRecognizer>(
() => _CustomLongPressGestureRecognizer(),
_registerCallbacks,
),
},
child: widget.child,
);
}
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
}
TimelineAssetIndex? _getValueKeyAtPosition(Offset position) {
final box = context.findAncestorRenderObjectOfType<RenderBox>();
if (box == null) return null;
final hitTestResult = BoxHitTestResult();
final local = box.globalToLocal(position);
if (!box.hitTest(hitTestResult, position: local)) return null;
return (hitTestResult.path.firstWhereOrNull((hit) => hit.target is _TimelineAssetIndexProxy)?.target
as _TimelineAssetIndexProxy?)
?.index;
}
void _onLongPressStart(LongPressStartDetails event) {
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
final height = context.size?.height;
if (height != null && (topScrollOffset == null || bottomScrollOffset == null)) {
topScrollOffset = height * scrollOffset;
bottomScrollOffset = height - topScrollOffset!;
}
final initialHit = _getValueKeyAtPosition(event.globalPosition);
anchorAsset = initialHit;
if (initialHit == null) return;
if (anchorAsset != null) {
widget.onStart?.call(anchorAsset!);
}
}
void _onLongPressEnd() {
scrollNotified = false;
scrollTimer?.cancel();
widget.onEnd?.call();
}
void _onLongPressMove(LongPressMoveUpdateDetails event) {
if (anchorAsset == null) return;
if (topScrollOffset == null || bottomScrollOffset == null) return;
final currentDy = event.localPosition.dy;
if (currentDy > bottomScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.forward),
);
} else if (currentDy < topScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.reverse),
);
} else {
scrollTimer?.cancel();
scrollTimer = null;
}
final currentlyTouchingAsset = _getValueKeyAtPosition(event.globalPosition);
if (currentlyTouchingAsset == null) return;
if (assetUnderPointer != currentlyTouchingAsset) {
if (!scrollNotified) {
scrollNotified = true;
widget.onScrollStart?.call();
}
widget.onAssetEnter?.call(currentlyTouchingAsset);
assetUnderPointer = currentlyTouchingAsset;
}
}
}
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
class TimelineAssetIndexWrapper extends SingleChildRenderObjectWidget {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndexWrapper({
required Widget super.child,
required this.assetIndex,
required this.segmentIndex,
super.key,
});
@override
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy createRenderObject(BuildContext context) {
return _TimelineAssetIndexProxy(
index: TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex),
);
}
@override
void updateRenderObject(
BuildContext context,
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy renderObject,
) {
renderObject.index = TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex);
}
}
class _TimelineAssetIndexProxy extends RenderProxyBox {
TimelineAssetIndex index;
_TimelineAssetIndexProxy({required this.index});
}
class TimelineAssetIndex {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndex({required this.assetIndex, required this.segmentIndex});
@override
bool operator ==(covariant TimelineAssetIndex other) {
if (identical(this, other)) return true;
return other.assetIndex == assetIndex && other.segmentIndex == segmentIndex;
}
@override
int get hashCode => assetIndex.hashCode ^ segmentIndex.hashCode;
}
+1 -1
View File
@@ -4,5 +4,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'api.provider.g.dart'; part 'api.provider.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true, dependencies: [])
ApiService apiService(Ref _) => ApiService(); ApiService apiService(Ref _) => ApiService();
+3 -3
View File
@@ -6,7 +6,7 @@ part of 'api.provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$apiServiceHash() => r'187a7de59b064fab1104c23717f18ce0ae3e426c'; String _$apiServiceHash() => r'46d8a043f41b85f36f56d81e5261c842cb5c0c06';
/// See also [apiService]. /// See also [apiService].
@ProviderFor(apiService) @ProviderFor(apiService)
@@ -16,8 +16,8 @@ final apiServiceProvider = Provider<ApiService>.internal(
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null ? null
: _$apiServiceHash, : _$apiServiceHash,
dependencies: null, dependencies: const <ProviderOrFamily>[],
allTransitiveDependencies: null, allTransitiveDependencies: const <ProviderOrFamily>{},
); );
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
@@ -22,7 +22,7 @@ import 'package:immich_mobile/providers/tab.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/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/backgroundLegacy.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -86,12 +86,11 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
// Ensure proper cleanup before starting new background tasks // Ensure proper cleanup before starting new background tasks
try { try {
await Future.wait([ await Future.wait([
Future(() async { backgroundManager.syncLocal().then((_) {
await backgroundManager.syncLocal();
Logger("AppLifeCycleNotifier").fine("Hashing assets after syncLocal"); Logger("AppLifeCycleNotifier").fine("Hashing assets after syncLocal");
// Check if app is still active before hashing // Check if app is still active before hashing
if ([AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state)) { if (state == AppLifeCycleEnum.resumed) {
await backgroundManager.hashAssets(); backgroundManager.hashAssets();
} }
}), }),
backgroundManager.syncRemote(), backgroundManager.syncRemote(),
+2 -4
View File
@@ -28,7 +28,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
ref.watch(secureStorageServiceProvider), ref.watch(secureStorageServiceProvider),
ref.watch(widgetServiceProvider), ref.watch(widgetServiceProvider),
); );
}); }, dependencies: [apiServiceProvider]);
class AuthNotifier extends StateNotifier<AuthState> { class AuthNotifier extends StateNotifier<AuthState> {
final AuthService _authService; final AuthService _authService;
@@ -121,9 +121,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<bool> saveAuthInfo({required String accessToken}) async { Future<bool> saveAuthInfo({required String accessToken}) async {
await _apiService.setAccessToken(accessToken); await _apiService.setAccessToken(accessToken);
final serverEndpoint = Store.get(StoreKey.serverEndpoint); await _widgetService.writeCredentials(Store.get(StoreKey.serverEndpoint), accessToken);
final customHeaders = Store.tryGet(StoreKey.customHeaders);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
// Get the deviceid from the store if it exists, otherwise generate a new one // Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
@@ -24,7 +24,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/backgroundLegacy.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/backup_album.service.dart';
import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/services/server_info.service.dart';
@@ -1,5 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/backgroundLegacy.service.dart'; import 'package:immich_mobile/services/background.service.dart';
class IOSBackgroundSettings { class IOSBackgroundSettings {
final bool appRefreshEnabled; final bool appRefreshEnabled;
@@ -21,7 +21,7 @@ import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/backgroundLegacy.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/backup_album.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
+2
View File
@@ -2,10 +2,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity; import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity;
import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/gcast.service.dart'; import 'package:immich_mobile/services/gcast.service.dart';
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>( final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
(ref) => CastNotifier(ref.watch(gCastServiceProvider)), (ref) => CastNotifier(ref.watch(gCastServiceProvider)),
dependencies: [apiServiceProvider],
); );
class CastNotifier extends StateNotifier<CastManagerState> { class CastNotifier extends StateNotifier<CastManagerState> {
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/local_album.service.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/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.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/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
@@ -31,7 +32,7 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>( final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)), (ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
dependencies: [remoteAlbumRepository], dependencies: [remoteAlbumRepository, apiServiceProvider],
); );
final remoteAlbumProvider = NotifierProvider<RemoteAlbumNotifier, RemoteAlbumState>( final remoteAlbumProvider = NotifierProvider<RemoteAlbumNotifier, RemoteAlbumState>(
@@ -156,23 +156,6 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<void> addUsers(String albumId, List<String> userIds) { Future<void> addUsers(String albumId, List<String> userIds) {
return _remoteAlbumService.addUsers(albumId: albumId, userIds: userIds); return _remoteAlbumService.addUsers(albumId: albumId, userIds: userIds);
} }
Future<void> removeUser(String albumId, String userId) {
return _remoteAlbumService.removeUser(albumId, userId: userId);
}
Future<void> leaveAlbum(String albumId, {required String userId}) async {
await _remoteAlbumService.removeUser(albumId, userId: userId);
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList();
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
}
Future<void> setActivityStatus(String albumId, bool enabled) {
return _remoteAlbumService.setActivityStatus(albumId, enabled);
}
} }
final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async { final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async {
@@ -1,8 +1,8 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>( final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>(
@@ -10,11 +10,6 @@ final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectSta
dependencies: [timelineServiceProvider], dependencies: [timelineServiceProvider],
); );
class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
class MultiSelectState { class MultiSelectState {
final Set<BaseAsset> selectedAssets; final Set<BaseAsset> selectedAssets;
final Set<BaseAsset> lockedSelectionAssets; final Set<BaseAsset> lockedSelectionAssets;
@@ -1,12 +1,16 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType;
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
final assetApiRepositoryProvider = Provider( final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository( (ref) => AssetApiRepository(
@@ -101,6 +105,20 @@ class AssetApiRepository extends ApiRepository {
Future<void> updateDescription(String assetId, String description) { Future<void> updateDescription(String assetId, String description) {
return _api.updateAsset(assetId, UpdateAssetDto(description: description)); return _api.updateAsset(assetId, UpdateAssetDto(description: description));
} }
Future<String?> uploadAsset(XFile file) async {
final lastModified = await file.lastModified();
final deviceAssetId = "MOBILE-${file.name}-${lastModified.millisecondsSinceEpoch}";
final multipart = MultipartFile.fromBytes(
'assetData', // field should be 'assetData' to match the backend API
await file.readAsBytes(),
filename: file.name,
);
final asset = await _api.uploadAsset(multipart, deviceAssetId, "MOBILE", lastModified, lastModified);
return asset?.id;
}
} }
extension on StackResponseDto { extension on StackResponseDto {
@@ -108,3 +126,41 @@ extension on StackResponseDto {
return StackResponse(id: id, primaryAssetId: primaryAssetId, assetIds: assets.map((asset) => asset.id).toList()); return StackResponse(id: id, primaryAssetId: primaryAssetId, assetIds: assets.map((asset) => asset.id).toList());
} }
} }
extension RemoteAssetDtoExt on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toType(),
);
}
}
extension on AssetTypeEnum {
AssetType toType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}
@@ -25,8 +25,6 @@ class AuthApiRepository extends ApiRepository {
} }
Future<void> logout() async { Future<void> logout() async {
if (_apiService.apiClient.basePath.isEmpty) return;
await _apiService.authenticationApi.logout().timeout(const Duration(seconds: 7)); await _apiService.authenticationApi.logout().timeout(const Duration(seconds: 7));
} }
@@ -1,7 +1,9 @@
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/album/shared_album.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
// ignore: import_rule_openapi // ignore: import_rule_openapi
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -88,13 +90,9 @@ class DriftAlbumApiRepository extends ApiRepository {
return response.toRemoteAlbum(); return response.toRemoteAlbum();
} }
Future<void> removeUser(String albumId, {required String userId}) async { Future<SharedRemoteAlbum?> getShared(String albumId) async {
await _api.removeUserFromAlbum(albumId, userId); final responseDto = await checkNull(_api.getAlbumInfo(albumId));
} return responseDto.toSharedRemoteAlbum();
Future<bool> setActivityStatus(String albumId, bool isEnabled) async {
final response = await checkNull(_api.updateAlbumInfo(albumId, UpdateAlbumDto(isActivityEnabled: isEnabled)));
return response.isActivityEnabled;
} }
} }
@@ -114,4 +112,21 @@ extension on AlbumResponseDto {
ownerName: owner.name, ownerName: owner.name,
); );
} }
SharedRemoteAlbum toSharedRemoteAlbum() {
return SharedRemoteAlbum(
id: id,
name: albumName,
ownerId: owner.id,
description: description,
createdAt: createdAt,
updatedAt: updatedAt,
thumbnailAssetId: albumThumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: assetCount,
ownerName: owner.name,
assets: assets.map((e) => e.toDto()).toList(),
);
}
} }
+6
View File
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -17,6 +18,11 @@ class AuthGuard extends AutoRouteGuard {
void onNavigation(NavigationResolver resolver, StackRouter router) async { void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true); resolver.next(true);
if (ImageUrlBuilder.isSharedLink()) {
// If the URL is a shared link, we don't need to validate the access token
return;
}
try { try {
// Look in the store for an access token // Look in the store for an access token
Store.get(StoreKey.accessToken); Store.get(StoreKey.accessToken);
+2 -2
View File
@@ -81,7 +81,6 @@ import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.da
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_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_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
@@ -101,6 +100,7 @@ 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/local_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/remote_shared_link.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';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
@@ -330,7 +330,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]), AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: RemoteSharedLinkRoute.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: '/'),
+52 -16
View File
@@ -667,22 +667,6 @@ class CropImageRouteArgs {
} }
} }
/// generated route for
/// [DriftAlbumOptionsPage]
class DriftAlbumOptionsRoute extends PageRouteInfo<void> {
const DriftAlbumOptionsRoute({List<PageRouteInfo>? children})
: super(DriftAlbumOptionsRoute.name, initialChildren: children);
static const String name = 'DriftAlbumOptionsRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftAlbumOptionsPage();
},
);
}
/// generated route for /// generated route for
/// [DriftAlbumsPage] /// [DriftAlbumsPage]
class DriftAlbumsRoute extends PageRouteInfo<void> { class DriftAlbumsRoute extends PageRouteInfo<void> {
@@ -2183,6 +2167,58 @@ class RemoteMediaSummaryRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [RemoteSharedLinkPage]
class RemoteSharedLinkRoute extends PageRouteInfo<RemoteSharedLinkRouteArgs> {
RemoteSharedLinkRoute({
Key? key,
required String shareKey,
required String endpoint,
List<PageRouteInfo>? children,
}) : super(
RemoteSharedLinkRoute.name,
args: RemoteSharedLinkRouteArgs(
key: key,
shareKey: shareKey,
endpoint: endpoint,
),
initialChildren: children,
);
static const String name = 'RemoteSharedLinkRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<RemoteSharedLinkRouteArgs>();
return RemoteSharedLinkPage(
key: args.key,
shareKey: args.shareKey,
endpoint: args.endpoint,
);
},
);
}
class RemoteSharedLinkRouteArgs {
const RemoteSharedLinkRouteArgs({
this.key,
required this.shareKey,
required this.endpoint,
});
final Key? key;
final String shareKey;
final String endpoint;
@override
String toString() {
return 'RemoteSharedLinkRouteArgs{key: $key, shareKey: $shareKey, endpoint: $endpoint}';
}
}
/// generated route for /// generated route for
/// [SearchPage] /// [SearchPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> { class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
+10 -1
View File
@@ -8,9 +8,9 @@ import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/user_agent.dart';
class ApiService implements Authentication { class ApiService implements Authentication {
late ApiClient _apiClient; late ApiClient _apiClient;
@@ -45,7 +45,14 @@ class ApiService implements Authentication {
setEndpoint(endpoint); setEndpoint(endpoint);
} }
} }
ApiService.shared(String endpoint, String sharedKey) {
setEndpoint(endpoint);
_queryParams = {'key': sharedKey};
}
String? _accessToken; String? _accessToken;
Map<String, String>? _queryParams;
final _log = Logger("ApiService"); final _log = Logger("ApiService");
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
@@ -208,6 +215,8 @@ class ApiService implements Authentication {
return Future<void>(() { return Future<void>(() {
var headers = ApiService.getRequestHeaders(); var headers = ApiService.getRequestHeaders();
headerParams.addAll(headers); headerParams.addAll(headers);
queryParams.addAll(_queryParams?.entries.map((e) => QueryParam(e.key, e.value)) ?? []);
}); });
} }
+13 -2
View File
@@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -80,6 +80,7 @@ class DeepLinkService {
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? ''), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? ''),
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
"sharedlink" => await _buildSharedLinkDeepLink(queryParams['key'] ?? '', queryParams['instanceUrl'] ?? ''),
_ => null, _ => null,
}; };
@@ -99,18 +100,24 @@ class DeepLinkService {
final path = link.uri.path; final path = link.uri.path;
const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
const b64Regex = r'^[A-Za-z0-9_-]+$';
final assetRegex = RegExp('/photos/($uuidRegex)'); final assetRegex = RegExp('/photos/($uuidRegex)');
final albumRegex = RegExp('/albums/($uuidRegex)'); final albumRegex = RegExp('/albums/($uuidRegex)');
final sharedLinkRegex = RegExp('/share/($b64Regex)');
PageRouteInfo<dynamic>? deepLinkRoute; PageRouteInfo<dynamic>? deepLinkRoute;
if (assetRegex.hasMatch(path)) { if (assetRegex.hasMatch(path)) {
final assetId = assetRegex.firstMatch(path)?.group(1) ?? ''; final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
deepLinkRoute = await _buildAssetDeepLink(assetId); deepLinkRoute = await _buildAssetDeepLink(assetId);
} else if (albumRegex.hasMatch(path)) { } else if (albumRegex.hasMatch(path)) {
final albumId = albumRegex.firstMatch(path)?.group(1) ?? ''; final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
deepLinkRoute = await _buildAlbumDeepLink(albumId); deepLinkRoute = await _buildAlbumDeepLink(albumId);
} else if (sharedLinkRegex.hasMatch(path)) {
final shareKey = sharedLinkRegex.firstMatch(path)?.group(1) ?? '';
final instanceUrl = link.uri.queryParameters['instanceUrl'] ?? '';
deepLinkRoute = await _buildSharedLinkDeepLink(shareKey, instanceUrl);
} }
// Deep link resolution failed, safely handle it based on the app state // Deep link resolution failed, safely handle it based on the app state
if (deepLinkRoute == null) { if (deepLinkRoute == null) {
if (isColdStart) return DeepLink.defaultPath; if (isColdStart) return DeepLink.defaultPath;
@@ -185,4 +192,8 @@ class DeepLinkService {
return AlbumViewerRoute(albumId: album.id); return AlbumViewerRoute(albumId: album.id);
} }
} }
Future<PageRouteInfo?> _buildSharedLinkDeepLink(String shareKey, String instanceUrl) async {
return RemoteSharedLinkRoute(shareKey: shareKey, endpoint: instanceUrl);
}
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import 'package:immich_mobile/domain/models/device_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/device_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/device_asset.provider.dart';
import 'package:immich_mobile/services/backgroundLegacy.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
class HashService { class HashService {
@@ -111,4 +111,13 @@ class SharedLinkService {
} }
return null; return null;
} }
Future<SharedLink?> getMySharedLink({String? password}) async {
final responseDto = await _apiService.sharedLinksApi.getMySharedLink(password: password);
if (responseDto != null) {
return SharedLink.fromDto(responseDto);
}
return null;
}
} }
+1 -6
View File
@@ -11,15 +11,11 @@ class WidgetService {
const WidgetService(this._repository); const WidgetService(this._repository);
Future<void> writeCredentials(String serverURL, String sessionKey, String? customHeaders) async { Future<void> writeCredentials(String serverURL, String sessionKey) async {
await _repository.setAppGroupId(appShareGroupId); await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, serverURL); await _repository.saveData(kWidgetServerEndpoint, serverURL);
await _repository.saveData(kWidgetAuthToken, sessionKey); await _repository.saveData(kWidgetAuthToken, sessionKey);
if (customHeaders != null && customHeaders.isNotEmpty) {
await _repository.saveData(kWidgetCustomHeaders, customHeaders);
}
// wait 3 seconds to ensure the widget is updated, dont block // wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets); Future.delayed(const Duration(seconds: 3), refreshWidgets);
} }
@@ -28,7 +24,6 @@ class WidgetService {
await _repository.setAppGroupId(appShareGroupId); await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, ""); await _repository.saveData(kWidgetServerEndpoint, "");
await _repository.saveData(kWidgetAuthToken, ""); await _repository.saveData(kWidgetAuthToken, "");
await _repository.saveData(kWidgetCustomHeaders, "");
// wait 3 seconds to ensure the widget is updated, dont block // wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets); Future.delayed(const Duration(seconds: 3), refreshWidgets);
+41 -6
View File
@@ -36,7 +36,7 @@ String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = Asset
} }
String getOriginalUrlForRemoteId(final String id) { String getOriginalUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original'; return ImageUrlBuilder.build('/assets/$id/original');
} }
String getImageCacheKey(final Asset asset) { String getImageCacheKey(final Asset asset) {
@@ -46,16 +46,51 @@ String getImageCacheKey(final Asset asset) {
} }
String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) { String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}'; return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${type.value}');
} }
String getPreviewUrlForRemoteId(final String id) => String getPreviewUrlForRemoteId(final String id) {
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}'; return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${AssetMediaSize.preview}');
}
String getPlaybackUrlForRemoteId(final String id) { String getPlaybackUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; return ImageUrlBuilder.build('/assets/$id/video/playback?');
} }
String getFaceThumbnailUrl(final String personId) { String getFaceThumbnailUrl(final String personId) {
return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail'; return ImageUrlBuilder.build('/people/$personId/thumbnail');
}
class ImageUrlBuilder {
static String? host;
static Map<String, String>? queryParams;
static void setHost(String? host) {
ImageUrlBuilder.host = host;
}
static bool isSharedLink() {
return ImageUrlBuilder.host != null && ImageUrlBuilder.queryParams!.containsKey('key');
}
static void setParameter(String key, String value) {
ImageUrlBuilder.queryParams ??= {};
ImageUrlBuilder.queryParams![key] = value;
}
static String build(String path) {
final endpoint = host ?? Store.get(StoreKey.serverEndpoint);
final uri = Uri.parse('$endpoint$path');
if (queryParams == null || queryParams!.isEmpty) {
return uri.toString();
}
final updatedUri = uri.replace(queryParameters: {...uri.queryParameters, ...queryParams!});
return updatedUri.toString();
}
static void clear() {
ImageUrlBuilder.host = null;
ImageUrlBuilder.queryParams = null;
}
} }
@@ -1,9 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class RemoteAlbumSharedUserIcons extends ConsumerWidget { class RemoteAlbumSharedUserIcons extends ConsumerWidget {
@@ -24,20 +22,17 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget {
return const SizedBox(); return const SizedBox();
} }
return GestureDetector( return SizedBox(
onTap: () => context.pushRoute(const DriftAlbumOptionsRoute()), height: 50,
child: SizedBox( child: ListView.builder(
height: 50, scrollDirection: Axis.horizontal,
child: ListView.builder( itemBuilder: ((context, index) {
scrollDirection: Axis.horizontal, return Padding(
itemBuilder: ((context, index) { padding: const EdgeInsets.only(right: 4.0),
return Padding( child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true),
padding: const EdgeInsets.only(right: 4.0), );
child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true), }),
); itemCount: sharedUsers.length,
}),
itemCount: sharedUsers.length,
),
), ),
); );
}, },

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