mirror of
https://github.com/immich-app/immich.git
synced 2025-12-16 09:40:45 -08:00
Compare commits
86 Commits
v2.3.1
...
refactor-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a739be31f3 | ||
|
|
62628dfcfa | ||
|
|
b11aecd184 | ||
|
|
116012f6f8 | ||
|
|
7594136050 | ||
|
|
bb341cc774 | ||
|
|
af1d4afb95 | ||
|
|
75b1ef2c57 | ||
|
|
1e37f7c8c8 | ||
|
|
a32f450059 | ||
|
|
b452ab463b | ||
|
|
79bed80226 | ||
|
|
6249996cdb | ||
|
|
a3f281caa3 | ||
|
|
7c19b0591f | ||
|
|
95c29a8aea | ||
|
|
d8ca210641 | ||
|
|
ab35afd3b1 | ||
|
|
65e4fdf98d | ||
|
|
fa43fae2a5 | ||
|
|
46afd6a101 | ||
|
|
46e1967760 | ||
|
|
922282b2b4 | ||
|
|
e3ab16a5bd | ||
|
|
08f320c801 | ||
|
|
e36261b552 | ||
|
|
c0a3b58bba | ||
|
|
f12f609038 | ||
|
|
1f6eb662e5 | ||
|
|
0c1fe35f2f | ||
|
|
e98a33cf9d | ||
|
|
d38305360c | ||
|
|
3e3ca4c104 | ||
|
|
81edf0749f | ||
|
|
01f83ae964 | ||
|
|
5eec0dc981 | ||
|
|
ca4fd07656 | ||
|
|
7ce43b3824 | ||
|
|
ce00119926 | ||
|
|
fffee80e2f | ||
|
|
64cd4e96e3 | ||
|
|
955a3bfaa6 | ||
|
|
e699d8f170 | ||
|
|
13104d49cd | ||
|
|
2d5ec528d5 | ||
|
|
5226898184 | ||
|
|
dd4169876c | ||
|
|
8321c275b8 | ||
|
|
3d6c26350a | ||
|
|
db15e5e423 | ||
|
|
35d18da14a | ||
|
|
cb56a11f0b | ||
|
|
104fa09f69 | ||
|
|
66ae07ee39 | ||
|
|
939d2c8b27 | ||
|
|
2801a6e672 | ||
|
|
4742360469 | ||
|
|
b56fa62b32 | ||
|
|
ddbe485074 | ||
|
|
01310c6d86 | ||
|
|
512327ef69 | ||
|
|
8755cd59fd | ||
|
|
7694b342ed | ||
|
|
78553a0258 | ||
|
|
c1198b99b7 | ||
|
|
8b7b9ee394 | ||
|
|
d6b39a464d | ||
|
|
75d23fe135 | ||
|
|
c860809aa1 | ||
|
|
0498f6cb9d | ||
|
|
24e5dabb51 | ||
|
|
aecf064ec9 | ||
|
|
57be3ff8c7 | ||
|
|
99505f987e | ||
|
|
1e1c4ac9d2 | ||
|
|
d952b62053 | ||
|
|
9f3eeed091 | ||
|
|
1dbc20fd77 | ||
|
|
ba8df712c4 | ||
|
|
741d838f56 | ||
|
|
ec2fa6e308 | ||
|
|
b974ed5735 | ||
|
|
78457d9b89 | ||
|
|
5d043b435e | ||
|
|
9a403d5886 | ||
|
|
1a31faf1a2 |
2
.github/.nvmrc
vendored
2
.github/.nvmrc
vendored
@@ -1 +1 @@
|
|||||||
24.11.0
|
24.11.1
|
||||||
|
|||||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
needs: [get_body, should_run]
|
needs: [get_body, should_run]
|
||||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
|
image: ghcr.io/immich-app/mdq:main@sha256:73a05fc805dfd3bd29bebc08442aedfec5c419c5ad3421ec73edc5647233891a
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,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@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.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.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.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
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
|||||||
suffixes: '-rocm'
|
suffixes: '-rocm'
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
runner-mapping: '{"linux/amd64": "mich"}'
|
runner-mapping: '{"linux/amd64": "mich"}'
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
name: Build and Push Server
|
name: Build and Push Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|||||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
2
.github/workflows/merge-translations.yml
vendored
2
.github/workflows/merge-translations.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
if: ${{ inputs.skip != true }}
|
if: ${{ inputs.skip != true }}
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
6
.github/workflows/prepare-release.yml
vendored
6
.github/workflows/prepare-release.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -126,7 +126,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
6
.github/workflows/release-pr.yml
vendored
6
.github/workflows/release-pr.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create PR
|
- name: Create PR
|
||||||
id: create-pr
|
id: create-pr
|
||||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
|||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Download APK
|
- name: Download APK
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -571,7 +571,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||||
# with:
|
# with:
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -52,7 +52,7 @@
|
|||||||
},
|
},
|
||||||
"cSpell.words": ["immich"],
|
"cSpell.words": ["immich"],
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"eslint.validate": ["javascript", "svelte"],
|
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
24.11.0
|
24.11.1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
|
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY package* pnpm* .pnpmfile.cjs ./
|
COPY package* pnpm* .pnpmfile.cjs ./
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^22.19.1",
|
"@types/node": "^24.10.1",
|
||||||
"@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",
|
||||||
@@ -28,7 +28,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": "^62.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": "24.11.0"
|
"node": "24.11.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ describe('crawl', () => {
|
|||||||
.map(([file]) => file);
|
.map(([file]) => file);
|
||||||
|
|
||||||
// Compare file's content instead of path since a file can be represent in multiple ways.
|
// Compare file's content instead of path since a file can be represent in multiple ways.
|
||||||
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
|
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
|
|||||||
ignore: [`**/${exclusionPattern}`],
|
ignore: [`**/${exclusionPattern}`],
|
||||||
});
|
});
|
||||||
globbedFiles.push(...crawledFiles);
|
globbedFiles.push(...crawledFiles);
|
||||||
return globbedFiles.sort();
|
return globbedFiles.toSorted();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sha1 = (filepath: string) => {
|
export const sha1 = (filepath: string) => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"target": "es2022",
|
"target": "es2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "0.91.2"
|
terragrunt = "0.93.10"
|
||||||
opentofu = "1.10.6"
|
opentofu = "1.10.7"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -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.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
|
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
|
||||||
volumes:
|
volumes:
|
||||||
- grafana-data:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
24.11.0
|
24.11.1
|
||||||
|
|||||||
@@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
|
|||||||
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
|
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
|
||||||
the job may not have run automatically the first time.
|
the job may not have run automatically the first time.
|
||||||
|
|
||||||
### How can I hide photos from the timeline?
|
### How can I hide a photo or video from the timeline?
|
||||||
|
|
||||||
You can _archive_ them.
|
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
|
||||||
|
|
||||||
### How can I backup data from Immich?
|
### How can I backup data from Immich?
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
|
|||||||
- [ ] `pnpm run check:typescript` (check typescript)
|
- [ ] `pnpm run check:typescript` (check typescript)
|
||||||
- [ ] `pnpm test` (unit tests)
|
- [ ] `pnpm test` (unit tests)
|
||||||
|
|
||||||
|
:::tip AIO
|
||||||
|
Run all web checks with `pnpm run check:all`
|
||||||
|
:::
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [ ] `pnpm run format` (formatting via Prettier)
|
- [ ] `pnpm run format` (formatting via Prettier)
|
||||||
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
|
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
|
||||||
|
|
||||||
:::tip AIO
|
|
||||||
Run all web checks with `pnpm run check:all`
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Server Checks
|
## Server Checks
|
||||||
|
|
||||||
- [ ] `pnpm run lint` (linting via ESLint)
|
- [ ] `pnpm run lint` (linting via ESLint)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ make e2e
|
|||||||
Before you can run the tests, you need to run the following commands _once_:
|
Before you can run the tests, you need to run the following commands _once_:
|
||||||
|
|
||||||
- `pnpm install` (in `e2e/`)
|
- `pnpm install` (in `e2e/`)
|
||||||
|
- `pnpm run build` (in `cli/`)
|
||||||
- `make open-api` (in the project root `/`)
|
- `make open-api` (in the project root `/`)
|
||||||
|
|
||||||
Once the test environment is running, the e2e tests can be run via:
|
Once the test environment is running, the e2e tests can be run via:
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
|
|||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default | Containers |
|
||||||
| :------------ | :------------- | :----------------------------------------: |
|
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
|
||||||
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
|
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
|
||||||
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
|
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
|||||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
||||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
|
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
||||||
|
|
||||||
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
|||||||
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
|
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
|
||||||
|
|
||||||
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
|
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
|
||||||
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
|
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
|
||||||
|
|
||||||
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
|
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.11.0"
|
"node": "24.11.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
24.11.0
|
24.11.1
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
|
cache_from:
|
||||||
|
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
|
||||||
|
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
|
||||||
args:
|
args:
|
||||||
- BUILD_ID=1234567890
|
- BUILD_ID=1234567890
|
||||||
- BUILD_IMAGE=e2e
|
- BUILD_IMAGE=e2e
|
||||||
|
|||||||
@@ -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.19.1",
|
"@types/node": "^24.10.1",
|
||||||
"@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,8 +35,8 @@
|
|||||||
"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": "^62.0.0",
|
||||||
"exiftool-vendored": "^31.1.0",
|
"exiftool-vendored": "^33.0.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jose": "^5.6.3",
|
"jose": "^5.6.3",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-organize-imports": "^4.0.0",
|
"prettier-plugin-organize-imports": "^4.0.0",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.5",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
@@ -54,6 +54,6 @@
|
|||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.11.0"
|
"node": "24.11.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...selectedDays].sort((a, b) => b - a);
|
return [...selectedDays].toSorted((a, b) => b - a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/assets/**', async (route, request) => {
|
await context.route('**/api/assets/*', async (route, request) => {
|
||||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
|
||||||
const match = request.url().match(pattern);
|
|
||||||
if (!match) {
|
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
const assetId = basename(pathname);
|
const assetId = basename(pathname);
|
||||||
@@ -75,37 +72,50 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
json: asset,
|
json: asset,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/*/ocr', async (route) => {
|
||||||
|
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||||
|
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||||
|
const match = request.url().match(pattern);
|
||||||
|
if (!match?.groups) {
|
||||||
|
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||||
}
|
}
|
||||||
if (match.groups?.size === 'preview') {
|
|
||||||
|
if (match.groups.size === 'preview') {
|
||||||
if (!route.request().serviceWorker()) {
|
if (!route.request().serviceWorker()) {
|
||||||
return route.continue();
|
return route.continue();
|
||||||
}
|
}
|
||||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||||
body: await randomPreview(
|
body: await randomPreview(
|
||||||
match.groups?.assetId,
|
match.groups.assetId,
|
||||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (match.groups?.size === 'thumbnail') {
|
if (match.groups.size === 'thumbnail') {
|
||||||
if (!route.request().serviceWorker()) {
|
if (!route.request().serviceWorker()) {
|
||||||
return route.continue();
|
return route.continue();
|
||||||
}
|
}
|
||||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'content-type': 'image/jpeg' },
|
headers: { 'content-type': 'image/jpeg' },
|
||||||
body: await randomThumbnail(
|
body: await randomThumbnail(
|
||||||
match.groups?.assetId,
|
match.groups.assetId,
|
||||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/albums/**', async (route, request) => {
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
||||||
const match = request.url().match(pattern);
|
const match = request.url().match(pattern);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
QueueCommandDto,
|
QueueCommandDto,
|
||||||
QueueName,
|
QueueName,
|
||||||
QueuesResponseDto,
|
QueuesResponseLegacyDto,
|
||||||
SharedLinkCreateDto,
|
SharedLinkCreateDto,
|
||||||
UpdateLibraryDto,
|
UpdateLibraryDto,
|
||||||
UserAdminCreateDto,
|
UserAdminCreateDto,
|
||||||
@@ -564,13 +564,13 @@ export const utils = {
|
|||||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||||
},
|
},
|
||||||
|
|
||||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
|
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
||||||
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
||||||
const jobCounts = queues[queue].jobCounts;
|
const jobCounts = queues[queue].jobCounts;
|
||||||
return !jobCounts.active && !jobCounts.waiting;
|
return !jobCounts.active && !jobCounts.waiting;
|
||||||
},
|
},
|
||||||
|
|
||||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => {
|
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
|
||||||
// eslint-disable-next-line no-async-promise-executor
|
// eslint-disable-next-line no-async-promise-executor
|
||||||
return new Promise<void>(async (resolve, reject) => {
|
return new Promise<void>(async (resolve, reject) => {
|
||||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
||||||
|
|||||||
@@ -611,6 +611,53 @@ test.describe('Timeline', () => {
|
|||||||
await page.getByText('Photos', { exact: true }).click();
|
await page.getByText('Photos', { exact: true }).click();
|
||||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||||
});
|
});
|
||||||
|
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
|
||||||
|
const assetToFavorite = assets[0];
|
||||||
|
changes.assetArchivals.push(assetToFavorite.id);
|
||||||
|
await pageUtils.openArchivePage(page);
|
||||||
|
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
if (requestJson.isFavorite === undefined) {
|
||||||
|
return await route.continue();
|
||||||
|
}
|
||||||
|
const isFavorite = requestJson.isFavorite;
|
||||||
|
if (isFavorite) {
|
||||||
|
changes.assetFavorites.push(...requestJson.ids);
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||||
|
await page.getByLabel('Favorite').click();
|
||||||
|
await expect(favorite).resolves.toEqual({
|
||||||
|
isFavorite: true,
|
||||||
|
ids: [assetToFavorite.id],
|
||||||
|
});
|
||||||
|
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||||
|
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||||
|
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
if (requestJson.isFavorite === undefined) {
|
||||||
|
return await route.continue();
|
||||||
|
}
|
||||||
|
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByLabel('Remove from favorites').click();
|
||||||
|
await expect(unFavoriteRequest).resolves.toEqual({
|
||||||
|
isFavorite: false,
|
||||||
|
ids: [assetToFavorite.id],
|
||||||
|
});
|
||||||
|
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||||
|
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
|
||||||
|
});
|
||||||
test('open album, archive photo, open album, unarchive', async ({ page }) => {
|
test('open album, archive photo, open album, unarchive', async ({ page }) => {
|
||||||
const album = timelineRestData.album;
|
const album = timelineRestData.album;
|
||||||
await pageUtils.openAlbumPage(page, album.id);
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
@@ -633,8 +680,7 @@ test.describe('Timeline', () => {
|
|||||||
visibility: 'archive',
|
visibility: 'archive',
|
||||||
ids: [assetToArchive.id],
|
ids: [assetToArchive.id],
|
||||||
});
|
});
|
||||||
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
|
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||||
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
|
|
||||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
await page.getByRole('link').getByText('Archive').click();
|
await page.getByRole('link').getByText('Archive').click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
@@ -656,8 +702,7 @@ test.describe('Timeline', () => {
|
|||||||
visibility: 'timeline',
|
visibility: 'timeline',
|
||||||
ids: [assetToArchive.id],
|
ids: [assetToArchive.id],
|
||||||
});
|
});
|
||||||
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
|
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||||
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
|
||||||
await pageUtils.openAlbumPage(page, album.id);
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||||
});
|
});
|
||||||
@@ -712,6 +757,50 @@ test.describe('Timeline', () => {
|
|||||||
await page.getByText('Photos', { exact: true }).click();
|
await page.getByText('Photos', { exact: true }).click();
|
||||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||||
});
|
});
|
||||||
|
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
||||||
|
await pageUtils.openFavorites(page);
|
||||||
|
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||||
|
await page.getByLabel('Menu').click();
|
||||||
|
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
if (requestJson.visibility !== 'archive') {
|
||||||
|
return await route.continue();
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
changes.assetArchivals.push(...requestJson.ids);
|
||||||
|
});
|
||||||
|
await page.getByRole('menuitem').getByText('Archive').click();
|
||||||
|
await expect(archive).resolves.toEqual({
|
||||||
|
visibility: 'archive',
|
||||||
|
ids: [assetToArchive.id],
|
||||||
|
});
|
||||||
|
await page.getByRole('link').getByText('Archive').click();
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||||
|
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||||
|
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
if (requestJson.visibility !== 'timeline') {
|
||||||
|
return await route.continue();
|
||||||
|
}
|
||||||
|
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByLabel('Unarchive').click();
|
||||||
|
await expect(unarchiveRequest).resolves.toEqual({
|
||||||
|
visibility: 'timeline',
|
||||||
|
ids: [assetToArchive.id],
|
||||||
|
});
|
||||||
|
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||||
|
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||||
|
});
|
||||||
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
|
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
|
||||||
const album = timelineRestData.album;
|
const album = timelineRestData.album;
|
||||||
await pageUtils.openAlbumPage(page, album.id);
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
|
|||||||
@@ -105,20 +105,16 @@ export const thumbnailUtils = {
|
|||||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
|
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
|
||||||
},
|
},
|
||||||
async expectThumbnailIsFavorite(page: Page, assetId: string) {
|
async expectThumbnailIsFavorite(page: Page, assetId: string) {
|
||||||
await expect(
|
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
|
||||||
thumbnailUtils
|
},
|
||||||
.withAssetId(page, assetId)
|
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
|
||||||
.locator(
|
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
|
||||||
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
|
|
||||||
),
|
|
||||||
).toHaveCount(1);
|
|
||||||
},
|
},
|
||||||
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
||||||
await expect(
|
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
|
||||||
thumbnailUtils
|
},
|
||||||
.withAssetId(page, assetId)
|
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||||
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
|
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||||
).toHaveCount(1);
|
|
||||||
},
|
},
|
||||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||||
// todo - need a data attribute for selected
|
// todo - need a data attribute for selected
|
||||||
@@ -208,10 +204,18 @@ export const pageUtils = {
|
|||||||
await page.goto(`/photos`);
|
await page.goto(`/photos`);
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
},
|
},
|
||||||
|
async openFavorites(page: Page) {
|
||||||
|
await page.goto(`/favorites`);
|
||||||
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
|
},
|
||||||
async openAlbumPage(page: Page, albumId: string) {
|
async openAlbumPage(page: Page, albumId: string) {
|
||||||
await page.goto(`/albums/${albumId}`);
|
await page.goto(`/albums/${albumId}`);
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
},
|
},
|
||||||
|
async openArchivePage(page: Page) {
|
||||||
|
await page.goto(`/archive`);
|
||||||
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
|
},
|
||||||
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
||||||
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ test.describe('User Administration', () => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
await page.getByText('Admin User').click();
|
await page.getByLabel('Admin User').click();
|
||||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ test.describe('User Administration', () => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
await page.getByText('Admin User').click();
|
await page.getByLabel('Admin User').click();
|
||||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"target": "es2022",
|
"target": "es2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
1
i18n/br.json
Normal file
1
i18n/br.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
13
i18n/en.json
13
i18n/en.json
@@ -67,6 +67,7 @@
|
|||||||
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
|
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
|
||||||
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
|
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
|
||||||
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
|
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
|
||||||
|
"copy_config_to_clipboard_description": "Copy the current system config as a JSON object to the clipboard",
|
||||||
"create_job": "Create job",
|
"create_job": "Create job",
|
||||||
"cron_expression": "Cron expression",
|
"cron_expression": "Cron expression",
|
||||||
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
||||||
@@ -74,6 +75,8 @@
|
|||||||
"disable_login": "Disable login",
|
"disable_login": "Disable login",
|
||||||
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
|
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
|
||||||
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
||||||
|
"export_config_as_json_description": "Download the current system config as a JSON file",
|
||||||
|
"external_libraries_page_description": "Admin external library page",
|
||||||
"external_library_management": "External Library Management",
|
"external_library_management": "External Library Management",
|
||||||
"face_detection": "Face detection",
|
"face_detection": "Face detection",
|
||||||
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
|
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
|
||||||
@@ -102,6 +105,7 @@
|
|||||||
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
|
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
|
||||||
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
|
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
|
||||||
"image_thumbnail_title": "Thumbnail Settings",
|
"image_thumbnail_title": "Thumbnail Settings",
|
||||||
|
"import_config_from_json_description": "Import system config by uploading a JSON config file",
|
||||||
"job_concurrency": "{job} concurrency",
|
"job_concurrency": "{job} concurrency",
|
||||||
"job_created": "Job created",
|
"job_created": "Job created",
|
||||||
"job_not_concurrency_safe": "This job is not concurrency-safe.",
|
"job_not_concurrency_safe": "This job is not concurrency-safe.",
|
||||||
@@ -110,6 +114,7 @@
|
|||||||
"job_status": "Job Status",
|
"job_status": "Job Status",
|
||||||
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
|
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
|
||||||
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
||||||
|
"jobs_page_description": "Admin jobs page",
|
||||||
"library_created": "Created library: {library}",
|
"library_created": "Created library: {library}",
|
||||||
"library_deleted": "Library deleted",
|
"library_deleted": "Library deleted",
|
||||||
"library_details": "Library details",
|
"library_details": "Library details",
|
||||||
@@ -182,6 +187,7 @@
|
|||||||
"maintenance_start": "Start maintenance mode",
|
"maintenance_start": "Start maintenance mode",
|
||||||
"maintenance_start_error": "Failed to start maintenance mode.",
|
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||||
"manage_concurrency": "Manage Concurrency",
|
"manage_concurrency": "Manage Concurrency",
|
||||||
|
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
||||||
"manage_log_settings": "Manage log settings",
|
"manage_log_settings": "Manage log settings",
|
||||||
"map_dark_style": "Dark style",
|
"map_dark_style": "Dark style",
|
||||||
"map_enable_description": "Enable map features",
|
"map_enable_description": "Enable map features",
|
||||||
@@ -287,8 +293,10 @@
|
|||||||
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
|
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
|
||||||
"server_settings": "Server Settings",
|
"server_settings": "Server Settings",
|
||||||
"server_settings_description": "Manage server settings",
|
"server_settings_description": "Manage server settings",
|
||||||
|
"server_stats_page_description": "Admin server statistics page",
|
||||||
"server_welcome_message": "Welcome message",
|
"server_welcome_message": "Welcome message",
|
||||||
"server_welcome_message_description": "A message that is displayed on the login page.",
|
"server_welcome_message_description": "A message that is displayed on the login page.",
|
||||||
|
"settings_page_description": "Admin settings page",
|
||||||
"sidecar_job": "Sidecar metadata",
|
"sidecar_job": "Sidecar metadata",
|
||||||
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
|
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
|
||||||
"slideshow_duration_description": "Number of seconds to display each image",
|
"slideshow_duration_description": "Number of seconds to display each image",
|
||||||
@@ -408,6 +416,7 @@
|
|||||||
"user_settings": "User Settings",
|
"user_settings": "User Settings",
|
||||||
"user_settings_description": "Manage user settings",
|
"user_settings_description": "Manage user settings",
|
||||||
"user_successfully_removed": "User {email} has been successfully removed.",
|
"user_successfully_removed": "User {email} has been successfully removed.",
|
||||||
|
"users_page_description": "Admin users page",
|
||||||
"version_check_enabled_description": "Enable version check",
|
"version_check_enabled_description": "Enable version check",
|
||||||
"version_check_implications": "The version check feature relies on periodic communication with github.com",
|
"version_check_implications": "The version check feature relies on periodic communication with github.com",
|
||||||
"version_check_settings": "Version Check",
|
"version_check_settings": "Version Check",
|
||||||
@@ -728,6 +737,7 @@
|
|||||||
"collapse_all": "Collapse all",
|
"collapse_all": "Collapse all",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"color_theme": "Color theme",
|
"color_theme": "Color theme",
|
||||||
|
"command": "Command",
|
||||||
"comment_deleted": "Comment deleted",
|
"comment_deleted": "Comment deleted",
|
||||||
"comment_options": "Comment options",
|
"comment_options": "Comment options",
|
||||||
"comments_and_likes": "Comments & likes",
|
"comments_and_likes": "Comments & likes",
|
||||||
@@ -1512,6 +1522,7 @@
|
|||||||
"other_variables": "Other variables",
|
"other_variables": "Other variables",
|
||||||
"owned": "Owned",
|
"owned": "Owned",
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
|
"page": "Page",
|
||||||
"partner": "Partner",
|
"partner": "Partner",
|
||||||
"partner_can_access": "{partner} can access",
|
"partner_can_access": "{partner} can access",
|
||||||
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
|
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
|
||||||
@@ -2072,6 +2083,7 @@
|
|||||||
"to_select": "to select",
|
"to_select": "to select",
|
||||||
"to_trash": "Trash",
|
"to_trash": "Trash",
|
||||||
"toggle_settings": "Toggle settings",
|
"toggle_settings": "Toggle settings",
|
||||||
|
"toggle_theme_description": "Toggle theme",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"total_usage": "Total usage",
|
"total_usage": "Total usage",
|
||||||
"trash": "Trash",
|
"trash": "Trash",
|
||||||
@@ -2180,6 +2192,7 @@
|
|||||||
"view_album": "View Album",
|
"view_album": "View Album",
|
||||||
"view_all": "View All",
|
"view_all": "View All",
|
||||||
"view_all_users": "View all users",
|
"view_all_users": "View all users",
|
||||||
|
"view_asset_owners": "View asset owners",
|
||||||
"view_details": "View Details",
|
"view_details": "View Details",
|
||||||
"view_in_timeline": "View in timeline",
|
"view_in_timeline": "View in timeline",
|
||||||
"view_link": "View link",
|
"view_link": "View link",
|
||||||
|
|||||||
1
i18n/eo.json
Normal file
1
i18n/eo.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
i18n/ga.json
Normal file
1
i18n/ga.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
i18n/gsw.json
Normal file
1
i18n/gsw.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
i18n/gu.json
Normal file
1
i18n/gu.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
i18n/is.json
Normal file
1
i18n/is.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
i18n/km.json
Normal file
1
i18n/km.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
i18n/si.json
Normal file
1
i18n/si.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
i18n/uz.json
Normal file
1
i18n/uz.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
i18n/yue_Hant.json
Normal file
1
i18n/yue_Hant.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
ARG DEVICE=cpu
|
ARG DEVICE=cpu
|
||||||
|
|
||||||
FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu
|
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
|
||||||
|
|
||||||
FROM builder-cpu AS builder-openvino
|
FROM builder-cpu AS builder-openvino
|
||||||
|
|
||||||
@@ -22,10 +22,10 @@ FROM builder-cpu AS builder-rknn
|
|||||||
|
|
||||||
# Warning: 25GiB+ disk space required to pull this image
|
# Warning: 25GiB+ disk space required to pull this image
|
||||||
# TODO: find a way to reduce the image size
|
# TODO: find a way to reduce the image size
|
||||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
|
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
|
||||||
|
|
||||||
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
||||||
ARG ONNXRUNTIME_VERSION="v1.20.1"
|
ARG ONNXRUNTIME_VERSION="v1.22.1"
|
||||||
WORKDIR /code
|
WORKDIR /code
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
|
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
|
||||||
@@ -68,12 +68,12 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
|
|||||||
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu
|
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
|
||||||
|
|
||||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||||
MACHINE_LEARNING_MODEL_ARENA=false
|
MACHINE_LEARNING_MODEL_ARENA=false
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino
|
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
@@ -102,7 +102,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
|
|||||||
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
||||||
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
||||||
|
|
||||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
|
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
|
||||||
|
|
||||||
FROM prod-cpu AS prod-armnn
|
FROM prod-cpu AS prod-armnn
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class TextDetector(InferenceModel):
|
|||||||
ratio = float(self.max_resolution) / img.height
|
ratio = float(self.max_resolution) / img.height
|
||||||
else:
|
else:
|
||||||
ratio = float(self.max_resolution) / img.width
|
ratio = float(self.max_resolution) / img.width
|
||||||
|
ratio = min(ratio, 1.0)
|
||||||
|
|
||||||
resize_h = int(img.height * ratio)
|
resize_h = int(img.height * ratio)
|
||||||
resize_w = int(img.width * ratio)
|
resize_w = int(img.width * ratio)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
|
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
|
||||||
index d90a2a355..bb1a7de12 100644
|
index 2714e6f59..a69da76b4 100644
|
||||||
--- a/cmake/CMakeLists.txt
|
--- a/cmake/CMakeLists.txt
|
||||||
+++ b/cmake/CMakeLists.txt
|
+++ b/cmake/CMakeLists.txt
|
||||||
@@ -295,7 +295,7 @@ if (onnxruntime_USE_ROCM)
|
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
|
||||||
endif()
|
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
|
||||||
|
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
|
||||||
if (NOT CMAKE_HIP_ARCHITECTURES)
|
else()
|
||||||
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||||
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||||
endif()
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
file(GLOB rocm_cmake_components ${onnxruntime_ROCM_HOME}/lib/cmake/*)
|
|
||||||
|
|||||||
3598
machine-learning/uv.lock
generated
3598
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
experimental_monorepo_root = true
|
experimental_monorepo_root = true
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
node = "24.11.0"
|
node = "24.11.1"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.20.0"
|
pnpm = "10.22.0"
|
||||||
terragrunt = "0.91.2"
|
terragrunt = "0.93.10"
|
||||||
opentofu = "1.10.6"
|
opentofu = "1.10.7"
|
||||||
|
java = "25.0.1"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
[tools."github:CQLabs/homebrew-dcm"]
|
||||||
version = "1.30.0"
|
version = "1.30.0"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const double kUploadStatusCanceled = -2.0;
|
|||||||
|
|
||||||
const int kMinMonthsToEnableScrubberSnap = 12;
|
const int kMinMonthsToEnableScrubberSnap = 12;
|
||||||
|
|
||||||
const String kImmichAppStoreLink = "https://apps.apple.com/app/immich/id6449244941";
|
const String kImmichAppStoreLink = "https://apps.apple.com/app/immich/id1613945652";
|
||||||
const String kImmichPlayStoreLink = "https://play.google.com/store/apps/details?id=app.alextran.immich";
|
const String kImmichPlayStoreLink = "https://play.google.com/store/apps/details?id=app.alextran.immich";
|
||||||
const String kImmichLatestRelease = "https://github.com/immich-app/immich/releases/latest";
|
const String kImmichLatestRelease = "https://github.com/immich-app/immich/releases/latest";
|
||||||
|
|
||||||
|
|||||||
32
mobile/lib/domain/models/events.model.dart
Normal file
32
mobile/lib/domain/models/events.model.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
|
|
||||||
|
// Timeline Events
|
||||||
|
class TimelineReloadEvent extends Event {
|
||||||
|
const TimelineReloadEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollToTopEvent extends Event {
|
||||||
|
const ScrollToTopEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollToDateEvent extends Event {
|
||||||
|
final DateTime date;
|
||||||
|
|
||||||
|
const ScrollToDateEvent(this.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset Viewer Events
|
||||||
|
class ViewerOpenBottomSheetEvent extends Event {
|
||||||
|
final bool activitiesMode;
|
||||||
|
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewerReloadAssetEvent extends Event {
|
||||||
|
const ViewerReloadAssetEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-Select Events
|
||||||
|
class MultiSelectToggleEvent extends Event {
|
||||||
|
final bool isEnabled;
|
||||||
|
const MultiSelectToggleEvent(this.isEnabled);
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ enum StoreKey<T> {
|
|||||||
readonlyModeEnabled<bool>._(138),
|
readonlyModeEnabled<bool>._(138),
|
||||||
|
|
||||||
autoPlayVideo<bool>._(139),
|
autoPlayVideo<bool>._(139),
|
||||||
|
albumGridView<bool>._(140),
|
||||||
|
|
||||||
// Experimental stuff
|
// Experimental stuff
|
||||||
photoManagerCustomFilter<bool>._(1000),
|
photoManagerCustomFilter<bool>._(1000),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|
||||||
|
|
||||||
enum GroupAssetsBy { day, month, auto, none }
|
enum GroupAssetsBy { day, month, auto, none }
|
||||||
|
|
||||||
enum HeaderType { none, month, day, monthAndDay }
|
enum HeaderType { none, month, day, monthAndDay }
|
||||||
@@ -31,17 +29,3 @@ class TimeBucket extends Bucket {
|
|||||||
@override
|
@override
|
||||||
int get hashCode => super.hashCode ^ date.hashCode;
|
int get hashCode => super.hashCode ^ date.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineReloadEvent extends Event {
|
|
||||||
const TimelineReloadEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScrollToTopEvent extends Event {
|
|
||||||
const ScrollToTopEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScrollToDateEvent extends Event {
|
|
||||||
final DateTime date;
|
|
||||||
|
|
||||||
const ScrollToDateEvent(this.date);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -75,6 +75,20 @@ class AssetService {
|
|||||||
isFlipped = false;
|
isFlipped = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (width == null || height == null) {
|
||||||
|
if (asset.hasRemote) {
|
||||||
|
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
||||||
|
final remoteAsset = await _remoteAssetRepository.get(id);
|
||||||
|
width = remoteAsset?.width?.toDouble();
|
||||||
|
height = remoteAsset?.height?.toDouble();
|
||||||
|
} else {
|
||||||
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||||
|
final localAsset = await _localAssetRepository.get(id);
|
||||||
|
width = localAsset?.width?.toDouble();
|
||||||
|
height = localAsset?.height?.toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final orientedWidth = isFlipped ? height : width;
|
final orientedWidth = isFlipped ? height : width;
|
||||||
final orientedHeight = isFlipped ? width : height;
|
final orientedHeight = isFlipped ? width : height;
|
||||||
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
|
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
|
||||||
|
|||||||
@@ -363,14 +363,14 @@ extension on Iterable<PlatformAsset> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on PlatformAsset {
|
extension PlatformToLocalAsset on PlatformAsset {
|
||||||
LocalAsset toLocalAsset() => LocalAsset(
|
LocalAsset toLocalAsset() => LocalAsset(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
checksum: null,
|
checksum: null,
|
||||||
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
|
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
|
||||||
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||||
updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
durationInSeconds: durationInSeconds,
|
durationInSeconds: durationInSeconds,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||||
|
|
||||||
class RemoteAlbumService {
|
class RemoteAlbumService {
|
||||||
final DriftRemoteAlbumRepository _repository;
|
final DriftRemoteAlbumRepository _repository;
|
||||||
@@ -32,16 +33,16 @@ class RemoteAlbumService {
|
|||||||
|
|
||||||
Future<List<RemoteAlbum>> sortAlbums(
|
Future<List<RemoteAlbum>> sortAlbums(
|
||||||
List<RemoteAlbum> albums,
|
List<RemoteAlbum> albums,
|
||||||
RemoteAlbumSortMode sortMode, {
|
AlbumSortMode sortMode, {
|
||||||
bool isReverse = false,
|
bool isReverse = false,
|
||||||
}) async {
|
}) async {
|
||||||
final List<RemoteAlbum> sorted = switch (sortMode) {
|
final List<RemoteAlbum> sorted = switch (sortMode) {
|
||||||
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
||||||
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
|
AlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||||
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||||
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||||
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||||
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (isReverse ? sorted.reversed : sorted).toList();
|
return (isReverse ? sorted.reversed : sorted).toList();
|
||||||
@@ -211,16 +212,3 @@ class RemoteAlbumService {
|
|||||||
return sorted.reversed.toList();
|
return sorted.reversed.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RemoteAlbumSortMode {
|
|
||||||
title("library_page_sort_title"),
|
|
||||||
assetCount("library_page_sort_asset_count"),
|
|
||||||
lastModified("library_page_sort_last_modified"),
|
|
||||||
created("library_page_sort_created"),
|
|
||||||
mostRecent("sort_newest"),
|
|
||||||
mostOldest("sort_oldest");
|
|
||||||
|
|
||||||
final String key;
|
|
||||||
|
|
||||||
const RemoteAlbumSortMode(this.key);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/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/services/setting.service.dart';
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:timezone/timezone.dart';
|
import 'package:immich_mobile/utils/timezone.dart';
|
||||||
|
|
||||||
extension TZExtension on Asset {
|
extension TZExtension on Asset {
|
||||||
/// Returns the created time of the asset from the exif info (if available) or from
|
/// Returns the created time of the asset from the exif info (if available) or from
|
||||||
@@ -7,24 +7,11 @@ extension TZExtension on Asset {
|
|||||||
/// the timezone offset in [Duration]
|
/// the timezone offset in [Duration]
|
||||||
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
|
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
|
||||||
DateTime dt = fileCreatedAt.toLocal();
|
DateTime dt = fileCreatedAt.toLocal();
|
||||||
|
|
||||||
if (exifInfo?.dateTimeOriginal != null) {
|
if (exifInfo?.dateTimeOriginal != null) {
|
||||||
dt = exifInfo!.dateTimeOriginal!;
|
return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone);
|
||||||
if (exifInfo?.timeZone != null) {
|
|
||||||
dt = dt.toUtc();
|
|
||||||
try {
|
|
||||||
final location = getLocation(exifInfo!.timeZone!);
|
|
||||||
dt = TZDateTime.from(dt, location);
|
|
||||||
} on LocationNotFoundException {
|
|
||||||
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
|
|
||||||
final m = re.firstMatch(exifInfo!.timeZone!);
|
|
||||||
if (m != null) {
|
|
||||||
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
|
|
||||||
dt = dt.add(duration);
|
|
||||||
return (dt, duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (dt, dt.timeZoneOffset);
|
return (dt, dt.timeZoneOffset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,7 +261,6 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
durationInSeconds: Value(asset.durationInSeconds),
|
durationInSeconds: Value(asset.durationInSeconds),
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
orientation: Value(asset.orientation),
|
orientation: Value(asset.orientation),
|
||||||
checksum: const Value(null),
|
|
||||||
isFavorite: Value(asset.isFavorite),
|
isFavorite: Value(asset.isFavorite),
|
||||||
);
|
);
|
||||||
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
row.deletedAt.isNull() &
|
row.deletedAt.isNull() &
|
||||||
row.isFavorite.equals(true) &
|
row.isFavorite.equals(true) &
|
||||||
row.ownerId.equals(userId) &
|
row.ownerId.equals(userId) &
|
||||||
row.visibility.equalsValue(AssetVisibility.timeline),
|
(row.visibility.equalsValue(AssetVisibility.timeline) | row.visibility.equalsValue(AssetVisibility.archive)),
|
||||||
groupBy: groupBy,
|
groupBy: groupBy,
|
||||||
origin: TimelineOrigin.favorite,
|
origin: TimelineOrigin.favorite,
|
||||||
);
|
);
|
||||||
|
|||||||
7
mobile/lib/models/auth/oauth_login_data.model.dart
Normal file
7
mobile/lib/models/auth/oauth_login_data.model.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class OAuthLoginData {
|
||||||
|
final String serverUrl;
|
||||||
|
final String state;
|
||||||
|
final String codeVerifier;
|
||||||
|
|
||||||
|
const OAuthLoginData({required this.serverUrl, required this.state, required this.codeVerifier});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'package:easy_localization/easy_localization.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/timeline.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class SettingsPage extends StatelessWidget {
|
|||||||
context.locale;
|
context.locale;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
|
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
|
||||||
body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()),
|
body: context.isMobile ? const _MobileLayout() : const _TabletLayout(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,11 +89,7 @@ class _MobileLayout extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
return ListView(
|
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
|
||||||
physics: const ClampingScrollPhysics(),
|
|
||||||
padding: const EdgeInsets.only(top: 10.0, bottom: 16),
|
|
||||||
children: [...settings],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.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/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class LoginPage extends HookConsumerWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: LoginForm(),
|
body: const LoginForm(),
|
||||||
bottomNavigationBar: SafeArea(
|
bottomNavigationBar: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16.0),
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
const DriftMemoryPage({required this.memories, required this.memoryIndex, super.key});
|
const DriftMemoryPage({required this.memories, required this.memoryIndex, super.key});
|
||||||
|
|
||||||
|
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
||||||
|
if (memory.assets.isNotEmpty) {
|
||||||
|
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
|
||||||
|
|
||||||
|
if (memory.assets.first.isVideo) {
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final currentMemory = useState(memories[memoryIndex]);
|
final currentMemory = useState(memories[memoryIndex]);
|
||||||
@@ -202,6 +212,10 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||||||
if (pageNumber < memories.length) {
|
if (pageNumber < memories.length) {
|
||||||
currentMemoryIndex.value = pageNumber;
|
currentMemoryIndex.value = pageNumber;
|
||||||
currentMemory.value = memories[pageNumber];
|
currentMemory.value = memories[pageNumber];
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
DriftMemoryPage.setMemory(ref, memories[pageNumber]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAssetPage.value = 0;
|
currentAssetPage.value = 0;
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class AddActionButton extends ConsumerWidget {
|
|||||||
color: context.themeData.scaffoldBackgroundColor,
|
color: context.themeData.scaffoldBackgroundColor,
|
||||||
position: _menuPosition(context),
|
position: _menuPosition(context),
|
||||||
items: items,
|
items: items,
|
||||||
|
popUpAnimationStyle: AnimationStyle.noAnimation,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.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';
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.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';
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.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';
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.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';
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.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';
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.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';
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
|||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.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';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
|
|
||||||
// used to allow performing unarchive action from different sources (without duplicating code)
|
// used to allow performing unarchive action from different sources (without duplicating code)
|
||||||
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
@@ -17,6 +16,9 @@ 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/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/providers/album/album_sort_by_options.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/album_filter.utils.dart';
|
import 'package:immich_mobile/utils/album_filter.utils.dart';
|
||||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||||
@@ -45,14 +47,28 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
|||||||
List<RemoteAlbum> shownAlbums = [];
|
List<RemoteAlbum> shownAlbums = [];
|
||||||
|
|
||||||
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
|
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
|
||||||
AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true);
|
AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Load albums when component mounts
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final appSettings = ref.read(appSettingsServiceProvider);
|
||||||
|
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
|
||||||
|
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
|
||||||
|
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
|
||||||
|
|
||||||
|
final albumSortMode = AlbumSortMode.values.firstWhere(
|
||||||
|
(e) => e.storeIndex == savedSortMode,
|
||||||
|
orElse: () => AlbumSortMode.lastModified,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
|
||||||
|
isGrid = savedIsGrid;
|
||||||
|
});
|
||||||
|
|
||||||
ref.read(remoteAlbumProvider.notifier).refresh();
|
ref.read(remoteAlbumProvider.notifier).refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +98,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
isGrid = !isGrid;
|
isGrid = !isGrid;
|
||||||
});
|
});
|
||||||
|
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
|
||||||
}
|
}
|
||||||
|
|
||||||
void changeFilter(QuickFilterMode mode) {
|
void changeFilter(QuickFilterMode mode) {
|
||||||
@@ -97,6 +114,10 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
|||||||
this.sort = sort;
|
this.sort = sort;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final appSettings = ref.read(appSettingsServiceProvider);
|
||||||
|
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
|
||||||
|
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
|
||||||
|
|
||||||
await sortAlbums();
|
await sortAlbums();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +202,8 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
|||||||
onToggleViewMode: toggleViewMode,
|
onToggleViewMode: toggleViewMode,
|
||||||
onSortChanged: changeSort,
|
onSortChanged: changeSort,
|
||||||
controller: menuController,
|
controller: menuController,
|
||||||
|
currentSortMode: sort.mode,
|
||||||
|
currentIsReverse: sort.isReverse,
|
||||||
),
|
),
|
||||||
isGrid
|
isGrid
|
||||||
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
|
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
|
||||||
@@ -192,21 +215,46 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SortButton extends ConsumerStatefulWidget {
|
class _SortButton extends ConsumerStatefulWidget {
|
||||||
const _SortButton(this.onSortChanged, {this.controller});
|
const _SortButton(
|
||||||
|
this.onSortChanged, {
|
||||||
|
required this.initialSortMode,
|
||||||
|
required this.initialIsReverse,
|
||||||
|
this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
final Future<void> Function(AlbumSort) onSortChanged;
|
final Future<void> Function(AlbumSort) onSortChanged;
|
||||||
final MenuController? controller;
|
final MenuController? controller;
|
||||||
|
final AlbumSortMode initialSortMode;
|
||||||
|
final bool initialIsReverse;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_SortButton> createState() => _SortButtonState();
|
ConsumerState<_SortButton> createState() => _SortButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SortButtonState extends ConsumerState<_SortButton> {
|
class _SortButtonState extends ConsumerState<_SortButton> {
|
||||||
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
|
late AlbumSortMode albumSortOption;
|
||||||
bool albumSortIsReverse = true;
|
late bool albumSortIsReverse;
|
||||||
bool isSorting = false;
|
bool isSorting = false;
|
||||||
|
|
||||||
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
albumSortOption = widget.initialSortMode;
|
||||||
|
albumSortIsReverse = widget.initialIsReverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_SortButton oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) {
|
||||||
|
setState(() {
|
||||||
|
albumSortOption = widget.initialSortMode;
|
||||||
|
albumSortIsReverse = widget.initialIsReverse;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onMenuTapped(AlbumSortMode sortMode) async {
|
||||||
final selected = albumSortOption == sortMode;
|
final selected = albumSortOption == sortMode;
|
||||||
// Switch direction
|
// Switch direction
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -240,7 +288,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
|||||||
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
|
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
|
||||||
),
|
),
|
||||||
consumeOutsideTap: true,
|
consumeOutsideTap: true,
|
||||||
menuChildren: RemoteAlbumSortMode.values
|
menuChildren: AlbumSortMode.values
|
||||||
.map(
|
.map(
|
||||||
(sortMode) => MenuItemButton(
|
(sortMode) => MenuItemButton(
|
||||||
leadingIcon: albumSortOption == sortMode
|
leadingIcon: albumSortOption == sortMode
|
||||||
@@ -269,7 +317,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
sortMode.key.t(context: context),
|
sortMode.label.t(context: context),
|
||||||
style: context.textTheme.titleSmall?.copyWith(
|
style: context.textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: albumSortOption == sortMode
|
color: albumSortOption == sortMode
|
||||||
@@ -298,7 +346,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
|||||||
: const Icon(Icons.keyboard_arrow_up_rounded),
|
: const Icon(Icons.keyboard_arrow_up_rounded),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
albumSortOption.key.t(context: context),
|
albumSortOption.label.t(context: context),
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: context.colorScheme.onSurface.withAlpha(225),
|
color: context.colorScheme.onSurface.withAlpha(225),
|
||||||
@@ -465,6 +513,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
|||||||
required this.isGrid,
|
required this.isGrid,
|
||||||
required this.onToggleViewMode,
|
required this.onToggleViewMode,
|
||||||
required this.onSortChanged,
|
required this.onSortChanged,
|
||||||
|
required this.currentSortMode,
|
||||||
|
required this.currentIsReverse,
|
||||||
this.controller,
|
this.controller,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,6 +522,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
|||||||
final VoidCallback onToggleViewMode;
|
final VoidCallback onToggleViewMode;
|
||||||
final MenuController? controller;
|
final MenuController? controller;
|
||||||
final Future<void> Function(AlbumSort) onSortChanged;
|
final Future<void> Function(AlbumSort) onSortChanged;
|
||||||
|
final AlbumSortMode currentSortMode;
|
||||||
|
final bool currentIsReverse;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -481,7 +533,12 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_SortButton(onSortChanged, controller: controller),
|
_SortButton(
|
||||||
|
onSortChanged,
|
||||||
|
controller: controller,
|
||||||
|
initialSortMode: currentSortMode,
|
||||||
|
initialIsReverse: currentIsReverse,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
|
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
|
||||||
onPressed: onToggleViewMode,
|
onPressed: onToggleViewMode,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
import 'package:immich_mobile/domain/models/events.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/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
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/utils/event_stream.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
class ViewerOpenBottomSheetEvent extends Event {
|
|
||||||
final bool activitiesMode;
|
|
||||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
|
||||||
}
|
|
||||||
|
|
||||||
class ViewerReloadAssetEvent extends Event {
|
|
||||||
const ViewerReloadAssetEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetViewerState {
|
class AssetViewerState {
|
||||||
final int backgroundOpacity;
|
final int backgroundOpacity;
|
||||||
final bool showingBottomSheet;
|
final bool showingBottomSheet;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
@@ -29,6 +30,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
|
import 'package:immich_mobile/utils/timezone.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
const _kSeparator = ' • ';
|
const _kSeparator = ' • ';
|
||||||
@@ -85,13 +87,21 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
class _AssetDetailBottomSheet extends ConsumerWidget {
|
class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||||
const _AssetDetailBottomSheet();
|
const _AssetDetailBottomSheet();
|
||||||
|
|
||||||
String _getDateTime(BuildContext ctx, BaseAsset asset) {
|
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
||||||
final dateTime = asset.createdAt.toLocal();
|
DateTime dateTime = asset.createdAt.toLocal();
|
||||||
|
Duration timeZoneOffset = dateTime.timeZoneOffset;
|
||||||
|
|
||||||
|
// Use EXIF timezone information if available (matching web app behavior)
|
||||||
|
if (exifInfo?.dateTimeOriginal != null) {
|
||||||
|
(dateTime, timeZoneOffset) = applyTimezoneOffset(
|
||||||
|
dateTime: exifInfo!.dateTimeOriginal!,
|
||||||
|
timeZone: exifInfo.timeZone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||||
final timezone = dateTime.timeZoneOffset.isNegative
|
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
|
||||||
? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'
|
|
||||||
: 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}';
|
|
||||||
return '$date$_kSeparator$time $timezone';
|
return '$date$_kSeparator$time $timezone';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +279,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Asset Date and Time
|
// Asset Date and Time
|
||||||
SheetTile(
|
SheetTile(
|
||||||
title: _getDateTime(context, asset),
|
title: _getDateTime(context, asset, exifInfo),
|
||||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
import 'package:immich_mobile/domain/models/events.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/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|||||||
@@ -143,13 +143,15 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
|||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
"enable_backup".t(context: context),
|
"enable_backup".t(context: context),
|
||||||
style: context.textTheme.titleMedium?.copyWith(
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: context.primaryColor,
|
color: context.primaryColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (enqueueCount != enqueueTotalCount)
|
if (enqueueCount != enqueueTotalCount)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
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';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
import 'package:immich_mobile/domain/models/memory.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';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ 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/memory.model.dart';
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/drift_memory.page.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/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
@@ -31,16 +30,9 @@ class DriftMemoryLane extends ConsumerWidget {
|
|||||||
overlayColor: WidgetStateProperty.all(Colors.white.withValues(alpha: 0.1)),
|
overlayColor: WidgetStateProperty.all(Colors.white.withValues(alpha: 0.1)),
|
||||||
onTap: (index) {
|
onTap: (index) {
|
||||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||||
|
|
||||||
if (memories[index].assets.isNotEmpty) {
|
if (memories[index].assets.isNotEmpty) {
|
||||||
final asset = memories[index].assets[0];
|
DriftMemoryPage.setMemory(ref, memories[index]);
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
|
||||||
|
|
||||||
if (asset.isVideo) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
|
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
|
||||||
},
|
},
|
||||||
children: memories
|
children: memories
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.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';
|
||||||
|
|||||||
@@ -12,13 +12,29 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
|||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/auth.service.dart';
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||||
|
import 'package:immich_mobile/services/server_info.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:immich_mobile/services/widget.service.dart';
|
import 'package:immich_mobile/services/widget.service.dart';
|
||||||
import 'package:immich_mobile/utils/hash.dart';
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
|
import 'package:immich_mobile/utils/url_helper.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/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
|
|
||||||
|
class ServerAuthSettings {
|
||||||
|
final String endpoint;
|
||||||
|
final bool isOAuthEnabled;
|
||||||
|
final bool isPasswordLoginEnabled;
|
||||||
|
final String oAuthButtonText;
|
||||||
|
|
||||||
|
const ServerAuthSettings({
|
||||||
|
required this.endpoint,
|
||||||
|
required this.isOAuthEnabled,
|
||||||
|
required this.isPasswordLoginEnabled,
|
||||||
|
required this.oAuthButtonText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||||
return AuthNotifier(
|
return AuthNotifier(
|
||||||
ref.watch(authServiceProvider),
|
ref.watch(authServiceProvider),
|
||||||
@@ -27,6 +43,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
|||||||
ref.watch(uploadServiceProvider),
|
ref.watch(uploadServiceProvider),
|
||||||
ref.watch(secureStorageServiceProvider),
|
ref.watch(secureStorageServiceProvider),
|
||||||
ref.watch(widgetServiceProvider),
|
ref.watch(widgetServiceProvider),
|
||||||
|
ref.watch(serverInfoServiceProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,6 +54,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
final UploadService _uploadService;
|
final UploadService _uploadService;
|
||||||
final SecureStorageService _secureStorageService;
|
final SecureStorageService _secureStorageService;
|
||||||
final WidgetService _widgetService;
|
final WidgetService _widgetService;
|
||||||
|
final ServerInfoService _serverInfoService;
|
||||||
final _log = Logger("AuthenticationNotifier");
|
final _log = Logger("AuthenticationNotifier");
|
||||||
|
|
||||||
static const Duration _timeoutDuration = Duration(seconds: 7);
|
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||||
@@ -48,6 +66,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
this._uploadService,
|
this._uploadService,
|
||||||
this._secureStorageService,
|
this._secureStorageService,
|
||||||
this._widgetService,
|
this._widgetService,
|
||||||
|
this._serverInfoService,
|
||||||
) : super(
|
) : super(
|
||||||
const AuthState(
|
const AuthState(
|
||||||
deviceId: "",
|
deviceId: "",
|
||||||
@@ -64,6 +83,27 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
return _authService.validateServerUrl(url);
|
return _authService.validateServerUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ServerAuthSettings?> getServerAuthSettings(String serverUrl) async {
|
||||||
|
final sanitizedUrl = sanitizeUrl(serverUrl);
|
||||||
|
final encodedUrl = punycodeEncodeUrl(sanitizedUrl);
|
||||||
|
|
||||||
|
final endpoint = await _authService.validateServerUrl(encodedUrl);
|
||||||
|
|
||||||
|
final features = await _serverInfoService.getServerFeatures();
|
||||||
|
final config = await _serverInfoService.getServerConfig();
|
||||||
|
|
||||||
|
if (features == null || config == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServerAuthSettings(
|
||||||
|
endpoint: endpoint,
|
||||||
|
isOAuthEnabled: features.oauthEnabled,
|
||||||
|
isPasswordLoginEnabled: features.passwordLogin,
|
||||||
|
oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Validating the url is the alternative connecting server url without
|
/// Validating the url is the alternative connecting server url without
|
||||||
/// saving the information to the local database
|
/// saving the information to the local database
|
||||||
Future<bool> validateAuxilaryServerUrl(String url) async {
|
Future<bool> validateAuxilaryServerUrl(String url) async {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
|||||||
|
|
||||||
Future<List<RemoteAlbum>> sortAlbums(
|
Future<List<RemoteAlbum>> sortAlbums(
|
||||||
List<RemoteAlbum> albums,
|
List<RemoteAlbum> albums,
|
||||||
RemoteAlbumSortMode sortMode, {
|
AlbumSortMode sortMode, {
|
||||||
bool isReverse = false,
|
bool isReverse = false,
|
||||||
}) async {
|
}) async {
|
||||||
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);
|
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/services/oauth.service.dart';
|
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/oauth.service.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
export 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
|
||||||
|
|
||||||
final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));
|
final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));
|
||||||
|
|
||||||
|
final oAuthProvider = StateNotifierProvider<OAuthNotifier, AsyncValue<void>>(
|
||||||
|
(ref) => OAuthNotifier(ref.watch(oAuthServiceProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
class OAuthNotifier extends StateNotifier<AsyncValue<void>> {
|
||||||
|
final OAuthService _oAuthService;
|
||||||
|
|
||||||
|
OAuthNotifier(this._oAuthService) : super(const AsyncValue.data(null));
|
||||||
|
|
||||||
|
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) {
|
||||||
|
return _oAuthService.getOAuthLoginData(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
|
||||||
|
return _oAuthService.completeOAuthLogin(oAuthData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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 +9,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;
|
||||||
|
|||||||
@@ -245,23 +245,15 @@ class AppRouter extends RootStackRouter {
|
|||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||||
),
|
),
|
||||||
CustomRoute(page: FolderRoute.page, guards: [_authGuard], transitionsBuilder: TransitionsBuilders.fadeIn),
|
AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
|
||||||
AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
CustomRoute(
|
AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
page: TrashRoute.page,
|
AutoRoute(page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
guards: [_authGuard, _duplicateGuard],
|
|
||||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
|
||||||
),
|
|
||||||
CustomRoute(
|
|
||||||
page: SharedLinkRoute.page,
|
|
||||||
guards: [_authGuard, _duplicateGuard],
|
|
||||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
|
||||||
),
|
|
||||||
AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
CustomRoute(
|
CustomRoute(
|
||||||
page: ActivitiesRoute.page,
|
page: ActivitiesRoute.page,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|||||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/utils/timezone.dart';
|
||||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||||
@@ -175,9 +176,17 @@ class ActionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final exifData = await _remoteAssetRepository.getExif(assetId);
|
final exifData = await _remoteAssetRepository.getExif(assetId);
|
||||||
initialDate = asset.createdAt.toLocal();
|
|
||||||
offset = initialDate.timeZoneOffset;
|
// Use EXIF timezone information if available (matching web app and display behavior)
|
||||||
timeZone = exifData?.timeZone;
|
DateTime dt = asset.createdAt.toLocal();
|
||||||
|
offset = dt.timeZoneOffset;
|
||||||
|
|
||||||
|
if (exifData?.dateTimeOriginal != null) {
|
||||||
|
timeZone = exifData!.timeZone;
|
||||||
|
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
initialDate = dt;
|
||||||
}
|
}
|
||||||
|
|
||||||
final dateTime = await showDateTimePicker(
|
final dateTime = await showDateTimePicker(
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ enum AppSettingsEnum<T> {
|
|||||||
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
||||||
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
||||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
||||||
|
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
||||||
|
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
|
||||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
|
||||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
|
||||||
|
|
||||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||||
|
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -11,6 +17,50 @@ class OAuthService {
|
|||||||
final log = Logger('OAuthService');
|
final log = Logger('OAuthService');
|
||||||
OAuthService(this._apiService);
|
OAuthService(this._apiService);
|
||||||
|
|
||||||
|
String _generateRandomString(int length) {
|
||||||
|
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
|
||||||
|
final random = Random.secure();
|
||||||
|
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _randomBytes(int length) {
|
||||||
|
final random = Random.secure();
|
||||||
|
return List<int>.generate(length, (i) => random.nextInt(256));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per specification, the code verifier must be 43-128 characters long
|
||||||
|
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
|
||||||
|
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
|
||||||
|
String _randomCodeVerifier() {
|
||||||
|
return base64Url.encode(_randomBytes(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generatePKCECodeChallenge(String codeVerifier) {
|
||||||
|
final bytes = utf8.encode(codeVerifier);
|
||||||
|
final digest = sha256.convert(bytes);
|
||||||
|
return base64Url.encode(digest.bytes).replaceAll('=', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initiates OAuth login flow.
|
||||||
|
/// Returns the OAuth server URL to redirect to, along with PKCE parameters.
|
||||||
|
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) async {
|
||||||
|
final state = _generateRandomString(32);
|
||||||
|
final codeVerifier = _randomCodeVerifier();
|
||||||
|
final codeChallenge = _generatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
final oAuthServerUrl = await getOAuthServerUrl(sanitizeUrl(serverUrl), state, codeChallenge);
|
||||||
|
|
||||||
|
if (oAuthServerUrl == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OAuthLoginData(serverUrl: oAuthServerUrl, state: state, codeVerifier: codeVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
|
||||||
|
return oAuthLogin(oAuthData.serverUrl, oAuthData.state, oAuthData.codeVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
|
Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
|
||||||
// Resolve API server endpoint from user provided serverUrl
|
// Resolve API server endpoint from user provided serverUrl
|
||||||
await _apiService.resolveAndSetEndpoint(serverUrl);
|
await _apiService.resolveAndSetEndpoint(serverUrl);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
|
||||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||||
|
|
||||||
class AlbumFilter {
|
class AlbumFilter {
|
||||||
String? userId;
|
String? userId;
|
||||||
@@ -14,12 +14,12 @@ class AlbumFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AlbumSort {
|
class AlbumSort {
|
||||||
RemoteAlbumSortMode mode;
|
AlbumSortMode mode;
|
||||||
bool isReverse;
|
bool isReverse;
|
||||||
|
|
||||||
AlbumSort({required this.mode, this.isReverse = false});
|
AlbumSort({required this.mode, this.isReverse = false});
|
||||||
|
|
||||||
AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) {
|
AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) {
|
||||||
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
|
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
// ignore: import_rule_photo_manager
|
// ignore: import_rule_photo_manager
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
const int targetVersion = 18;
|
const int targetVersion = 19;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||||
@@ -78,6 +80,12 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
|||||||
await Store.put(StoreKey.shouldResetSync, true);
|
await Store.put(StoreKey.shouldResetSync, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 19 && Store.isBetaTimelineEnabled) {
|
||||||
|
if (!await _populateUpdatedAtTime(drift)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (targetVersion >= 12) {
|
if (targetVersion >= 12) {
|
||||||
await Store.put(StoreKey.version, targetVersion);
|
await Store.put(StoreKey.version, targetVersion);
|
||||||
return;
|
return;
|
||||||
@@ -221,6 +229,32 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _populateUpdatedAtTime(Drift db) async {
|
||||||
|
try {
|
||||||
|
final nativeApi = NativeSyncApi();
|
||||||
|
final albums = await nativeApi.getAlbums();
|
||||||
|
for (final album in albums) {
|
||||||
|
final assets = await nativeApi.getAssetsForAlbum(album.id);
|
||||||
|
await db.batch((batch) async {
|
||||||
|
for (final asset in assets) {
|
||||||
|
batch.update(
|
||||||
|
db.localAssetEntity,
|
||||||
|
LocalAssetEntityCompanion(
|
||||||
|
updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()),
|
||||||
|
),
|
||||||
|
where: (t) => t.id.equals(asset.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
dPrint(() => "[MIGRATION] Error while populating updatedAt time: $error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
||||||
try {
|
try {
|
||||||
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
|
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
|
||||||
|
|||||||
35
mobile/lib/utils/timezone.dart
Normal file
35
mobile/lib/utils/timezone.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:timezone/timezone.dart';
|
||||||
|
|
||||||
|
/// Applies timezone conversion to a DateTime using EXIF timezone information.
|
||||||
|
///
|
||||||
|
/// This function handles two timezone formats:
|
||||||
|
/// 1. Named timezone locations (e.g., "Asia/Hong_Kong")
|
||||||
|
/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00")
|
||||||
|
///
|
||||||
|
/// Returns a tuple of (adjusted DateTime, timezone offset Duration)
|
||||||
|
(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) {
|
||||||
|
DateTime dt = dateTime.toUtc();
|
||||||
|
|
||||||
|
if (timeZone == null) {
|
||||||
|
return (dt, dt.timeZoneOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get timezone location from database
|
||||||
|
final location = getLocation(timeZone);
|
||||||
|
dt = TZDateTime.from(dt, location);
|
||||||
|
return (dt, dt.timeZoneOffset);
|
||||||
|
} on LocationNotFoundException {
|
||||||
|
// Handle UTC offset format (e.g., "UTC+08:00")
|
||||||
|
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
|
||||||
|
final m = re.firstMatch(timeZone);
|
||||||
|
if (m != null) {
|
||||||
|
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
|
||||||
|
dt = dt.add(duration);
|
||||||
|
return (dt, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If timezone is invalid, return UTC
|
||||||
|
return (dt, dt.timeZoneOffset);
|
||||||
|
}
|
||||||
@@ -193,7 +193,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.pop();
|
context.pop();
|
||||||
launchUrl(Uri.parse('https://immich.app'), mode: LaunchMode.externalApplication);
|
launchUrl(Uri.parse('https://docs.immich.app'), mode: LaunchMode.externalApplication);
|
||||||
},
|
},
|
||||||
child: Text("documentation", style: context.textTheme.bodySmall).tr(),
|
child: Text("documentation", style: context.textTheme.bodySmall).tr(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
|||||||
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/timeline.model.dart';
|
import 'package:immich_mobile/domain/models/events.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/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user