Compare commits
176 Commits
v1.28.2_40
...
v1.33.0_52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25848b78f9 | ||
|
|
f94176a910 | ||
|
|
ae96508e15 | ||
|
|
95ebf815eb | ||
|
|
b713fb5650 | ||
|
|
6159c83fd2 | ||
|
|
f1af17bf4d | ||
|
|
a87c1c1210 | ||
|
|
e63d165b65 | ||
|
|
9411770253 | ||
|
|
dc80ac1c88 | ||
|
|
bb055628cc | ||
|
|
390bcdb8c6 | ||
|
|
d95bcb46ad | ||
|
|
7b954e21e7 | ||
|
|
a6eea4d096 | ||
|
|
2c189d5c78 | ||
|
|
85a80fd032 | ||
|
|
0309b47515 | ||
|
|
95d8f60389 | ||
|
|
1ec7122381 | ||
|
|
061b229e12 | ||
|
|
3617433858 | ||
|
|
d6d525cc1b | ||
|
|
e752290458 | ||
|
|
d77e25425e | ||
|
|
028c0249a3 | ||
|
|
a3ca5307a5 | ||
|
|
6796462b13 | ||
|
|
d08475d5af | ||
|
|
d310c77fc8 | ||
|
|
75d8ca1306 | ||
|
|
894eea739e | ||
|
|
1156290377 | ||
|
|
c271f0c224 | ||
|
|
a7f14dc103 | ||
|
|
f05d5bdb9e | ||
|
|
e99c400f59 | ||
|
|
e38166837d | ||
|
|
d43a08eb71 | ||
|
|
293e713af6 | ||
|
|
03866b4c31 | ||
|
|
4f2c08525f | ||
|
|
2c12f53937 | ||
|
|
c88e5f9be2 | ||
|
|
0f51a9794e | ||
|
|
edd1f49e57 | ||
|
|
4df0cf2d07 | ||
|
|
87ba99755b | ||
|
|
c03f860f8e | ||
|
|
f2e0e3f345 | ||
|
|
fee652dfd7 | ||
|
|
839446a88d | ||
|
|
028b8c8bcc | ||
|
|
64b1d4ca3b | ||
|
|
c6cbee6563 | ||
|
|
a406f6e7cc | ||
|
|
9869b92c2b | ||
|
|
00549eed79 | ||
|
|
0c4968dc30 | ||
|
|
704335c898 | ||
|
|
ec74feea5a | ||
|
|
2f5cc3059a | ||
|
|
4355485581 | ||
|
|
342c3254cb | ||
|
|
5fc82dfaa2 | ||
|
|
6ab6507db9 | ||
|
|
3c807ae86e | ||
|
|
9bfacaa39a | ||
|
|
a2882a4908 | ||
|
|
1adc64a352 | ||
|
|
c28863966b | ||
|
|
14dc679332 | ||
|
|
17085dd8a0 | ||
|
|
82b8313da0 | ||
|
|
4f7e764fa0 | ||
|
|
d52da8bbea | ||
|
|
cdddcad784 | ||
|
|
38767cad0f | ||
|
|
c3d7dda61f | ||
|
|
c4e32ce159 | ||
|
|
6355a07dc4 | ||
|
|
0e3fb41e73 | ||
|
|
fdac5af5ee | ||
|
|
0e509ceafa | ||
|
|
6b84534632 | ||
|
|
fc255b558d | ||
|
|
9e54e30011 | ||
|
|
77312ce2e0 | ||
|
|
9a6d29d6e7 | ||
|
|
2cb7517f64 | ||
|
|
3228882fc0 | ||
|
|
6804e3dc73 | ||
|
|
f9af61a5ca | ||
|
|
a94b443f13 | ||
|
|
fd06aa2135 | ||
|
|
dd0f40559d | ||
|
|
471a60dcb0 | ||
|
|
46994c3355 | ||
|
|
642811869c | ||
|
|
3be4697487 | ||
|
|
a3aca4acb5 | ||
|
|
7587f858ae | ||
|
|
854c214bc0 | ||
|
|
5dfce4db34 | ||
|
|
95467fa3c1 | ||
|
|
4ec3453558 | ||
|
|
536fda04f2 | ||
|
|
2094204877 | ||
|
|
ab375cca1a | ||
|
|
479f706f8a | ||
|
|
4342285507 | ||
|
|
8bb656cb17 | ||
|
|
a117e897ca | ||
|
|
347ac70063 | ||
|
|
50842ef815 | ||
|
|
1970a64f6f | ||
|
|
dd71a53f5e | ||
|
|
3f1f835df3 | ||
|
|
8440d9890c | ||
|
|
87ca031335 | ||
|
|
96b9e37461 | ||
|
|
0d3a2fe844 | ||
|
|
848781aef5 | ||
|
|
28bf497a0b | ||
|
|
8ede738396 | ||
|
|
40c2b6a563 | ||
|
|
3581cf7305 | ||
|
|
c33775b944 | ||
|
|
b0cd2522e0 | ||
|
|
c3979f6e31 | ||
|
|
103df4d9f3 | ||
|
|
040e02cfc5 | ||
|
|
f377b64065 | ||
|
|
e5459b68ff | ||
|
|
fc194021a4 | ||
|
|
39f8ca3bf1 | ||
|
|
7a807f7216 | ||
|
|
bedfb51b1c | ||
|
|
b2afb95c19 | ||
|
|
10239161fd | ||
|
|
242f10952d | ||
|
|
e997bd371b | ||
|
|
400167f4ef | ||
|
|
572f6d833d | ||
|
|
2e06be5155 | ||
|
|
62121470a8 | ||
|
|
e3ccc3ee6b | ||
|
|
ece94f6bdc | ||
|
|
03fc0703c0 | ||
|
|
0d13b25f56 | ||
|
|
75c2067836 | ||
|
|
824da6a07b | ||
|
|
2c2ea24dc4 | ||
|
|
47b73a5b64 | ||
|
|
6b3f8e548d | ||
|
|
0ea483f901 | ||
|
|
97aed8ef23 | ||
|
|
0ee3fe9157 | ||
|
|
434770155f | ||
|
|
7e8bf94543 | ||
|
|
8d8944705c | ||
|
|
7c9c1a5169 | ||
|
|
1a6c16d8ea | ||
|
|
ccf792f9d3 | ||
|
|
789bc8563c | ||
|
|
99a50f70dd | ||
|
|
9bef411056 | ||
|
|
e79e92c60f | ||
|
|
858ad43d3b | ||
|
|
5761765ea7 | ||
|
|
6abc733763 | ||
|
|
4271e24e59 | ||
|
|
9e4ed2214b | ||
|
|
011332e509 | ||
|
|
5403ef4d84 |
24
.github/workflows/build_push_docker_latest.yml
vendored
24
.github/workflows/build_push_docker_latest.yml
vendored
@@ -17,17 +17,17 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and push Immich Mono Repo
|
- name: Build and push Immich Mono Repo
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
@@ -45,17 +45,17 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Machine Learning
|
- name: Build and Push Machine Learning
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./machine-learning
|
context: ./machine-learning
|
||||||
file: ./machine-learning/Dockerfile
|
file: ./machine-learning/Dockerfile
|
||||||
@@ -72,17 +72,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Web
|
- name: Build and Push Web
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./web
|
context: ./web
|
||||||
file: ./web/Dockerfile
|
file: ./web/Dockerfile
|
||||||
@@ -100,17 +100,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Proxy
|
- name: Build and Push Proxy
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
file: ./nginx/Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
|
|||||||
30
.github/workflows/build_push_docker_staging.yml
vendored
30
.github/workflows/build_push_docker_staging.yml
vendored
@@ -2,8 +2,6 @@ name: Build and Push Docker Image - Staging
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
@@ -19,10 +17,10 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ github.repository == 'immich-app/immich' }}
|
if: ${{ github.repository == 'immich-app/immich' }}
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -30,7 +28,7 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and push Immich Mono Repo
|
- name: Build and push Immich Mono Repo
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
@@ -38,6 +36,7 @@ jobs:
|
|||||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||||
tags: |
|
tags: |
|
||||||
altran1502/immich-server:staging
|
altran1502/immich-server:staging
|
||||||
|
altran1502/immich-server:${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
build_and_push_machine_learning_staging:
|
build_and_push_machine_learning_staging:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -48,10 +47,10 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ github.repository == 'immich-app/immich' }}
|
if: ${{ github.repository == 'immich-app/immich' }}
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -59,7 +58,7 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Machine Learning
|
- name: Build and Push Machine Learning
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./machine-learning
|
context: ./machine-learning
|
||||||
file: ./machine-learning/Dockerfile
|
file: ./machine-learning/Dockerfile
|
||||||
@@ -67,6 +66,7 @@ jobs:
|
|||||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||||
tags: |
|
tags: |
|
||||||
altran1502/immich-machine-learning:staging
|
altran1502/immich-machine-learning:staging
|
||||||
|
altran1502/immich-machine-learning:${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
build_and_push_web_staging:
|
build_and_push_web_staging:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -76,10 +76,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ github.repository == 'immich-app/immich' }}
|
if: ${{ github.repository == 'immich-app/immich' }}
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Web
|
- name: Build and Push Web
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./web
|
context: ./web
|
||||||
file: ./web/Dockerfile
|
file: ./web/Dockerfile
|
||||||
@@ -96,6 +96,7 @@ jobs:
|
|||||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||||
tags: |
|
tags: |
|
||||||
altran1502/immich-web:staging
|
altran1502/immich-web:staging
|
||||||
|
altran1502/immich-web:${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
build_and_push_nginx_staging:
|
build_and_push_nginx_staging:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -105,10 +106,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ github.repository == 'immich-app/immich' }}
|
if: ${{ github.repository == 'immich-app/immich' }}
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -116,7 +117,7 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Proxy
|
- name: Build and Push Proxy
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
file: ./nginx/Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
@@ -124,3 +125,4 @@ jobs:
|
|||||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||||
tags: |
|
tags: |
|
||||||
altran1502/immich-proxy:staging
|
altran1502/immich-proxy:staging
|
||||||
|
altran1502/immich-proxy:${{ github.event.pull_request.number }}
|
||||||
|
|||||||
24
.github/workflows/build_push_server_release.yml
vendored
24
.github/workflows/build_push_server_release.yml
vendored
@@ -22,11 +22,11 @@ jobs:
|
|||||||
fallback: latest
|
fallback: latest
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push immich-server release
|
- name: Build and push immich-server release
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
@@ -58,17 +58,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fallback: latest
|
fallback: latest
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Machine Learning
|
- name: Build and Push Machine Learning
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./machine-learning
|
context: ./machine-learning
|
||||||
file: ./machine-learning/Dockerfile
|
file: ./machine-learning/Dockerfile
|
||||||
@@ -94,11 +94,11 @@ jobs:
|
|||||||
fallback: latest
|
fallback: latest
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push immich-web release
|
- name: Build and push immich-web release
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./web
|
context: ./web
|
||||||
file: ./web/Dockerfile
|
file: ./web/Dockerfile
|
||||||
@@ -134,11 +134,11 @@ jobs:
|
|||||||
fallback: latest
|
fallback: latest
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.2.1
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push immich-proxy release
|
- name: Build and push immich-proxy release
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
file: ./nginx/Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
|
|||||||
74
.github/workflows/codeql-analysis.yml
vendored
Normal file
74
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
# The branches below must be a subset of the branches above
|
||||||
|
branches: [ "main" ]
|
||||||
|
schedule:
|
||||||
|
- cron: '20 13 * * 1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript', 'python' ]
|
||||||
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
|
||||||
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
# ℹ️ 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
|
||||||
|
|
||||||
|
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||||
|
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||||
|
|
||||||
|
# - run: |
|
||||||
|
# echo "Run, Build Application using script"
|
||||||
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
21
.github/workflows/dispatch_sdk_update.yml
vendored
Normal file
21
.github/workflows/dispatch_sdk_update.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: Update Immich SDK
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-sdk-repos:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GH_TOKEN }}
|
||||||
|
script: |
|
||||||
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
|
owner: 'immich-app',
|
||||||
|
repo: 'immich-sdk-typescript-axios',
|
||||||
|
workflow_id: 'build.yml',
|
||||||
|
ref: 'main'
|
||||||
|
})
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Run Immich Server 2E2 Test
|
- name: Run Immich Server E2E Test
|
||||||
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
|
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
|
||||||
|
|
||||||
server-unit-tests:
|
server-unit-tests:
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -23,19 +23,37 @@
|
|||||||
<br/>
|
<br/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
You can access the web demo at https://demo.immich.app
|
||||||
|
|
||||||
|
For the mobile app, you can use https://demo.immich.app/api for the `Server Endpoint URL`
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
The credential
|
||||||
|
email: demo@immich.app
|
||||||
|
password: demo
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||||
|
```
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Screenshots](#screenshots)
|
- [Screenshots](#screenshots)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Update](#update)
|
- [Update](#update)
|
||||||
- [Mobile App](#-mobile-app)
|
- [Mobile App](#mobile-app)
|
||||||
|
- [App Beta Invitation links](#App-Beta-release-channel)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Support](#support)
|
- [Support](#support)
|
||||||
- [Known Issues](#known-issues)
|
- [Known Issues](#known-issues)
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development, there will be continuous functions, features and api changes.
|
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development. There will be continuous functions, features and api changes.
|
||||||
|
|
||||||
| Features | Mobile | Web |
|
| Features | Mobile | Web |
|
||||||
| - | - | - |
|
| - | - | - |
|
||||||
@@ -98,13 +116,12 @@ There are several services that compose Immich:
|
|||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
|
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as `client_max_body_size` in the case of NGINX).
|
||||||
|
## Testing one-step installation (not recommended for production)
|
||||||
|
|
||||||
## Testing One-step installation (not recommended for production)
|
> ⚠️ *This installation method is for evaluating Immich before further customization to meet the users' needs.*
|
||||||
|
|
||||||
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
*Applicable operating systems: Ubuntu, Debian, MacOS*
|
||||||
|
|
||||||
*Applicable system: Ubuntu, Debian, MacOS*
|
|
||||||
|
|
||||||
- In the shell, from the directory of your choice, run the following command:
|
- In the shell, from the directory of your choice, run the following command:
|
||||||
|
|
||||||
@@ -146,7 +163,6 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
|||||||
* Populate custom database information if necessary.
|
* Populate custom database information if necessary.
|
||||||
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
|
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
|
||||||
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
|
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
|
||||||
* [Optional] Populate Mapbox value to use reverse geocoding.
|
|
||||||
|
|
||||||
### Step 3 - Start the containers
|
### Step 3 - Start the containers
|
||||||
|
|
||||||
@@ -180,16 +196,24 @@ If you have installed, you can update the application by navigate to the directo
|
|||||||
```bash
|
```bash
|
||||||
docker-compose pull && docker-compose up -d
|
docker-compose pull && docker-compose up -d
|
||||||
```
|
```
|
||||||
|
# Unraid Installation
|
||||||
|
|
||||||
|
Please follow this [article](https://mfaz.dev/posts/immich-unraid/) for a tutorial on how to install Immich on Unraid
|
||||||
|
|
||||||
|
|
||||||
# Mobile app
|
# Mobile app
|
||||||
|
|
||||||
| F-Droid | Google Play | iOS |
|
| F-Droid | Google Play | iOS |
|
||||||
| - | - | - |
|
| - | - | - |
|
||||||
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <img src="design/google-play-qr-code.png" width="200" title="Google Play Store"> <p/> | <p align="left"> <img src="design/ios-qr-code.png" width="200" title="Apple App Store"> <p/> |
|
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
|
||||||
|
|
||||||
> *The App version might be lagging behind the latest release due to the review process.*
|
> *The Play/App Store version might be lagging behind the latest release due to their review process.*
|
||||||
|
|
||||||
|
# App Beta release channel
|
||||||
|
|
||||||
|
You can opt-in to join app beta release channel by following the links below:
|
||||||
|
* Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||||
|
* iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
@@ -236,7 +260,7 @@ Cheers! 🎉
|
|||||||
|
|
||||||
## TensorFlow Build Issue
|
## TensorFlow Build Issue
|
||||||
|
|
||||||
*This is a known issue for incorrect Promox setup*
|
*This is a known issue for incorrect Proxmox setup*
|
||||||
|
|
||||||
TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
|
TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
|
||||||
|
|
||||||
@@ -244,7 +268,7 @@ TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX a
|
|||||||
more /proc/cpuinfo | grep flags
|
more /proc/cpuinfo | grep flags
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are running virtualization in Promox, the VM doesn't have the flag enabled.
|
If you are running virtualization in Proxmox, the VM doesn't have the flag enabled.
|
||||||
|
|
||||||
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
|
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
|
||||||
|
|
||||||
|
|||||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please report security issues to `alex.tran1502@gmail.com`
|
||||||
@@ -10,9 +10,6 @@ DB_DATABASE_NAME=immich
|
|||||||
# Optional Database settings:
|
# Optional Database settings:
|
||||||
# DB_PORT=5432
|
# DB_PORT=5432
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# Redis
|
# Redis
|
||||||
###################################################################################
|
###################################################################################
|
||||||
@@ -25,36 +22,42 @@ REDIS_HOSTNAME=immich_redis
|
|||||||
# REDIS_PASSWORD=
|
# REDIS_PASSWORD=
|
||||||
# REDIS_SOCKET=
|
# REDIS_SOCKET=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# Upload File Config
|
# Upload File Config
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
||||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||||
|
|
||||||
|
###################################################################################
|
||||||
|
# Log message level - [simple|verbose]
|
||||||
|
###################################################################################
|
||||||
|
|
||||||
|
LOG_LEVEL=simple
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# JWT SECRET
|
# JWT SECRET
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
||||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
# This JWT_SECRET is used to sign the authentication keys for user login
|
||||||
|
# You should set it to a long randomly generated value
|
||||||
|
# You can use this command to generate one: openssl rand -base64 128
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# MAPBOX
|
# Reverse Geocoding
|
||||||
####################################################################################
|
####################################################################################
|
||||||
|
|
||||||
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
# DISABLE_REVERSE_GEOCODING=false
|
||||||
ENABLE_MAPBOX=false
|
|
||||||
MAPBOX_KEY=
|
|
||||||
|
|
||||||
|
# Reverse geocoding is done locally which has a small impact on memory usage
|
||||||
|
# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
|
||||||
|
# This ranges from 0-3 with 3 being the most precise
|
||||||
|
# 3 - Cities > 500 population: ~200MB RAM
|
||||||
|
# 2 - Cities > 1000 population: ~150MB RAM
|
||||||
|
# 1 - Cities > 5000 population: ~80MB RAM
|
||||||
|
# 0 - Cities > 15000 population: ~40MB RAM
|
||||||
|
|
||||||
|
# REVERSE_GEOCODING_PRECISION=3
|
||||||
|
|
||||||
####################################################################################
|
####################################################################################
|
||||||
# WEB - Optional
|
# WEB - Optional
|
||||||
@@ -63,4 +66,4 @@ MAPBOX_KEY=
|
|||||||
# Custom message on the login page, should be written in HTML form.
|
# Custom message on the login page, should be written in HTML form.
|
||||||
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
|
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
|
||||||
|
|
||||||
PUBLIC_LOGIN_PAGE_MESSAGE=
|
PUBLIC_LOGIN_PAGE_MESSAGE=
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ ENABLE_MAPBOX=false
|
|||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
MAPBOX_KEY=
|
MAPBOX_KEY=
|
||||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ services:
|
|||||||
context: ../nginx
|
context: ../nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- 2283:80
|
- 2283:8080
|
||||||
- 2284:443
|
|
||||||
logging:
|
logging:
|
||||||
driver: none
|
driver: none
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -72,8 +72,7 @@ services:
|
|||||||
container_name: immich_proxy
|
container_name: immich_proxy
|
||||||
image: altran1502/immich-proxy:staging
|
image: altran1502/immich-proxy:staging
|
||||||
ports:
|
ports:
|
||||||
- 2283:80
|
- 2283:8080
|
||||||
- 2284:443
|
|
||||||
logging:
|
logging:
|
||||||
driver: none
|
driver: none
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
container_name: immich_proxy
|
container_name: immich_proxy
|
||||||
image: altran1502/immich-proxy:release
|
image: altran1502/immich-proxy:release
|
||||||
ports:
|
ports:
|
||||||
- 2283:80
|
- 2283:8080
|
||||||
logging:
|
logging:
|
||||||
driver: none
|
driver: none
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
48
install.sh
48
install.sh
@@ -2,48 +2,61 @@ echo "Starting Immich installation..."
|
|||||||
|
|
||||||
ip_address=$(hostname -I | awk '{print $1}')
|
ip_address=$(hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
release_version=$(curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" |
|
||||||
|
grep '"tag_name":' |
|
||||||
|
sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\032[0;31m'
|
GREEN='\032[0;31m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
get_release_version() {
|
||||||
|
curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" | # Get latest release from GitHub api
|
||||||
|
grep '"tag_name":' | # Get tag line
|
||||||
|
sed -E 's/.*"([^"]+)".*/\1/' # Pluck JSON value
|
||||||
|
}
|
||||||
|
|
||||||
create_immich_directory() {
|
create_immich_directory() {
|
||||||
echo "Creating Immich directory..."
|
echo "Creating Immich directory..."
|
||||||
mkdir -p ./immich-app/immich-data
|
mkdir -p ./immich-app/immich-data
|
||||||
|
cd ./immich-app
|
||||||
}
|
}
|
||||||
|
|
||||||
download_docker_compose_file() {
|
download_docker_compose_file() {
|
||||||
echo "Downloading docker-compose.yml..."
|
echo "Downloading docker-compose.yml..."
|
||||||
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
|
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./docker-compose.yml >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
download_dot_env_file() {
|
download_dot_env_file() {
|
||||||
echo "Downloading .env file..."
|
echo "Downloading .env file..."
|
||||||
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
|
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./.env >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
replace_env_value() {
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
sed -i '' "s|$1=.*|$1=$2|" ./.env
|
||||||
|
else
|
||||||
|
sed -i "s|$1=.*|$1=$2|" ./.env
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
populate_upload_location() {
|
populate_upload_location() {
|
||||||
echo "Populating default UPLOAD_LOCATION value..."
|
echo "Populating default UPLOAD_LOCATION value..."
|
||||||
|
upload_location=$(pwd)/immich-data
|
||||||
|
replace_env_value "UPLOAD_LOCATION" $upload_location
|
||||||
|
}
|
||||||
|
|
||||||
cd ./immich-app/immich-data
|
generate_jwt_secret() {
|
||||||
|
echo "Generating JWT_SECRET value..."
|
||||||
upload_location=$(pwd)
|
jwt_secret=$(openssl rand -base64 128)
|
||||||
|
replace_env_value "JWT_SECRET" $jwt_secret
|
||||||
# Replace value of UPLOAD_LOCATION in .env with upload_location path
|
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
||||||
sed -i '' "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
|
|
||||||
else
|
|
||||||
sed -i "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start_docker_compose() {
|
start_docker_compose() {
|
||||||
echo "Starting Immich's docker containers"
|
echo "Starting Immich's docker containers"
|
||||||
|
|
||||||
if docker compose &> /dev/null; then
|
if docker compose &>/dev/null; then
|
||||||
docker_bin="docker compose"
|
docker_bin="docker compose"
|
||||||
elif docker-compose &> /dev/null; then
|
elif docker-compose &>/dev/null; then
|
||||||
docker_bin="docker-compose"
|
docker_bin="docker-compose"
|
||||||
else
|
else
|
||||||
echo 'Cannot find `docker compose` or `docker-compose`.'
|
echo 'Cannot find `docker compose` or `docker-compose`.'
|
||||||
@@ -64,7 +77,7 @@ show_friendly_message() {
|
|||||||
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
|
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
|
||||||
echo "The backup (or upload) location is $upload_location"
|
echo "The backup (or upload) location is $upload_location"
|
||||||
echo "---------------------------------------------------"
|
echo "---------------------------------------------------"
|
||||||
echo "If you want to confgure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
||||||
|
|
||||||
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,
|
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,
|
||||||
|
|
||||||
@@ -79,4 +92,5 @@ create_immich_directory
|
|||||||
download_docker_compose_file
|
download_docker_compose_file
|
||||||
download_dot_env_file
|
download_dot_env_file
|
||||||
populate_upload_location
|
populate_upload_location
|
||||||
|
generate_jwt_secret
|
||||||
start_docker_compose
|
start_docker_compose
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ WORKDIR /usr/src/app
|
|||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
RUN apt-get update
|
RUN apt-get update > /dev/null \
|
||||||
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
&& apt-get install --no-install-recommends -y gcc g++ make cmake python3 python3-pip ffmpeg > /dev/null \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
RUN npm rebuild @tensorflow/tfjs-node --build-from-source
|
RUN npm rebuild @tensorflow/tfjs-node --build-from-source
|
||||||
@@ -23,6 +24,9 @@ FROM node:16-bullseye-slim
|
|||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
COPY LICENSE /licenses/LICENSE.txt
|
||||||
|
COPY LICENSE /LICENSE
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
@@ -30,13 +34,18 @@ COPY entrypoint.sh ./
|
|||||||
|
|
||||||
RUN mkdir -p /usr/src/app/dist \
|
RUN mkdir -p /usr/src/app/dist \
|
||||||
&& mkdir -p /usr/src/app/node_modules \
|
&& mkdir -p /usr/src/app/node_modules \
|
||||||
&& apt-get update \
|
&& mkdir -p /usr/src/app/.reverse-geocoding-dump \
|
||||||
&& apt-get install -y ffmpeg \
|
&& apt-get update > /dev/null \
|
||||||
&& rm -rf /var/cache/apt/lists
|
&& apt-get install --no-install-recommends -y ffmpeg > /dev/null \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/cache/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||||
COPY --from=builder /usr/src/app/dist ./dist
|
COPY --from=builder /usr/src/app/dist ./dist
|
||||||
|
|
||||||
RUN npm prune --production
|
RUN npm prune --production
|
||||||
|
|
||||||
# CMD [ "node", "dist/main" ]
|
RUN chown -R node:0 /usr/src/app \
|
||||||
|
&& chmod -R g=u /usr/src/app
|
||||||
|
|
||||||
|
RUN addgroup node root
|
||||||
21
machine-learning/LICENSE
Normal file
21
machine-learning/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Hau Tran
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
2227
machine-learning/package-lock.json
generated
2227
machine-learning/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@
|
|||||||
"@nestjs/core": "^8.0.0",
|
"@nestjs/core": "^8.0.0",
|
||||||
"@nestjs/mapped-types": "^1.0.1",
|
"@nestjs/mapped-types": "^1.0.1",
|
||||||
"@nestjs/platform-express": "^8.0.0",
|
"@nestjs/platform-express": "^8.0.0",
|
||||||
"@nestjs/typeorm": "^8.0.3",
|
|
||||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
"@tensorflow-models/coco-ssd": "^2.2.2",
|
||||||
"@tensorflow-models/mobilenet": "^2.1.0",
|
"@tensorflow-models/mobilenet": "^2.1.0",
|
||||||
"@tensorflow/tfjs": "^3.19.0",
|
"@tensorflow/tfjs": "^3.19.0",
|
||||||
@@ -34,11 +33,9 @@
|
|||||||
"@tensorflow/tfjs-node": "^3.19.0",
|
"@tensorflow/tfjs-node": "^3.19.0",
|
||||||
"@tensorflow/tfjs-node-gpu": "^3.19.0",
|
"@tensorflow/tfjs-node-gpu": "^3.19.0",
|
||||||
"@trpc/server": "^9.20.3",
|
"@trpc/server": "^9.20.3",
|
||||||
"pg": "^8.7.3",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0"
|
||||||
"typeorm": "^0.2.45"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^8.2.4",
|
"@nestjs/cli": "^8.2.4",
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
||||||
import { databaseConfig } from './config/database.config';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [ImageClassifierModule, ObjectDetectionModule],
|
||||||
TypeOrmModule.forRoot(databaseConfig),
|
|
||||||
ImageClassifierModule,
|
|
||||||
ObjectDetectionModule,
|
|
||||||
],
|
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
export const databaseConfig: TypeOrmModuleOptions = {
|
|
||||||
type: 'postgres',
|
|
||||||
host: process.env.DB_HOSTNAME || 'immich_postgres',
|
|
||||||
port: parseInt(process.env.DB_PORT || '5432'),
|
|
||||||
username: process.env.DB_USERNAME,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_DATABASE_NAME,
|
|
||||||
synchronize: false,
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich" xmlns:tools="http://schemas.android.com/tools">
|
||||||
<application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
|
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
|
||||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
@@ -12,12 +12,15 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
<service android:name=".AppClearedService" android:stopWithTask="false" />
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data android:name="flutterEmbedding" android:value="2" />
|
<meta-data android:name="flutterEmbedding" android:value="2" />
|
||||||
|
<!-- Disables default WorkManager initialization to use our custom initialization -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
tools:node="remove">
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package app.alextran.immich
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.IBinder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Catches the event when either the system or the user kills the app
|
|
||||||
* (does not apply on force close!)
|
|
||||||
*/
|
|
||||||
class AppClearedService() : Service() {
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
|
||||||
return START_NOT_STICKY;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent) {
|
|
||||||
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
|
|
||||||
stopSelf();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
* Android plugin for Dart `BackgroundService`
|
* Android plugin for Dart `BackgroundService`
|
||||||
*
|
*
|
||||||
* Receives messages/method calls from the foreground Dart side to manage
|
* Receives messages/method calls from the foreground Dart side to manage
|
||||||
* the background service, e.g. start (enqueue), stop (cancel)
|
* the background service, e.g. start (enqueue), stop (cancel)
|
||||||
*/
|
*/
|
||||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
|
|
||||||
@@ -38,14 +38,15 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val ctx = context!!
|
val ctx = context!!
|
||||||
when(call.method) {
|
when (call.method) {
|
||||||
"enable" -> {
|
"enable" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
.edit()
|
.edit()
|
||||||
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
|
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
|
||||||
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
|
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
|
||||||
.apply()
|
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
|
||||||
|
.apply()
|
||||||
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
|
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
@@ -54,7 +55,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
val requireUnmeteredNetwork = args.get(0) as Boolean
|
val requireUnmeteredNetwork = args.get(0) as Boolean
|
||||||
val requireCharging = args.get(1) as Boolean
|
val requireCharging = args.get(1) as Boolean
|
||||||
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
|
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
"disable" -> {
|
"disable" -> {
|
||||||
ContentObserverWorker.disable(ctx)
|
ContentObserverWorker.disable(ctx)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -47,6 +48,8 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
||||||
private var timeBackupStarted: Long = 0L
|
private var timeBackupStarted: Long = 0L
|
||||||
|
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||||
|
private var notificationDetailBuilder: NotificationCompat.Builder? = null
|
||||||
|
|
||||||
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
||||||
|
|
||||||
@@ -61,16 +64,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
// Create a Notification channel if necessary
|
// Create a Notification channel if necessary
|
||||||
createChannel()
|
createChannel()
|
||||||
}
|
}
|
||||||
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
|
||||||
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
|
||||||
if (isIgnoringBatteryOptimizations) {
|
if (isIgnoringBatteryOptimizations) {
|
||||||
// normal background services can only up to 10 minutes
|
// normal background services can only up to 10 minutes
|
||||||
// foreground services are allowed to run indefinitely
|
// foreground services are allowed to run indefinitely
|
||||||
// requires battery optimizations to be disabled (either manually by the user
|
// requires battery optimizations to be disabled (either manually by the user
|
||||||
// or by the system learning that immich is important to the user)
|
// or by the system learning that immich is important to the user)
|
||||||
setForegroundAsync(createForegroundInfo(title))
|
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
} else {
|
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
||||||
showBackgroundInfo(title)
|
showInfo(getInfoBuilder(title, indeterminate=true).build())
|
||||||
}
|
}
|
||||||
engine = FlutterEngine(ctx)
|
engine = FlutterEngine(ctx)
|
||||||
|
|
||||||
@@ -154,18 +155,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
}
|
}
|
||||||
"updateNotification" -> {
|
"updateNotification" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val title = args.get(0) as String
|
val title = args.get(0) as String?
|
||||||
val content = args.get(1) as String
|
val content = args.get(1) as String?
|
||||||
if (isIgnoringBatteryOptimizations) {
|
val progress = args.get(2) as Int
|
||||||
setForegroundAsync(createForegroundInfo(title, content))
|
val max = args.get(3) as Int
|
||||||
} else {
|
val indeterminate = args.get(4) as Boolean
|
||||||
showBackgroundInfo(title, content)
|
val isDetail = args.get(5) as Boolean
|
||||||
|
val onlyIfFG = args.get(6) as Boolean
|
||||||
|
if (!onlyIfFG || isIgnoringBatteryOptimizations) {
|
||||||
|
showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"showError" -> {
|
"showError" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val title = args.get(0) as String
|
val title = args.get(0) as String
|
||||||
val content = args.get(1) as String
|
val content = args.get(1) as String?
|
||||||
val individualTag = args.get(2) as String?
|
val individualTag = args.get(2) as String?
|
||||||
showError(title, content, individualTag)
|
showError(title, content, individualTag)
|
||||||
}
|
}
|
||||||
@@ -182,13 +186,12 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showError(title: String, content: String, individualTag: String?) {
|
private fun showError(title: String, content: String?, individualTag: String?) {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setTicker(title)
|
.setTicker(title)
|
||||||
.setContentText(content)
|
.setContentText(content)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.build()
|
.build()
|
||||||
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
|
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
|
||||||
}
|
}
|
||||||
@@ -197,38 +200,54 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setTicker(title)
|
|
||||||
.setContentText(content)
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setOngoing(true)
|
|
||||||
.build()
|
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearBackgroundNotification() {
|
private fun clearBackgroundNotification() {
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
|
notificationManager.cancel(NOTIFICATION_DETAIL_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
private fun showInfo(notification: Notification, isDetail: Boolean = false) {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID
|
||||||
.setContentTitle(title)
|
if (isIgnoringBatteryOptimizations) {
|
||||||
.setTicker(title)
|
setForegroundAsync(ForegroundInfo(id, notification))
|
||||||
.setContentText(content)
|
} else {
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
notificationManager.notify(id, notification)
|
||||||
.setOngoing(true)
|
}
|
||||||
.build()
|
}
|
||||||
return ForegroundInfo(NOTIFICATION_ID, notification)
|
|
||||||
}
|
private fun getInfoBuilder(
|
||||||
|
title: String? = null,
|
||||||
|
content: String? = null,
|
||||||
|
isDetail: Boolean = false,
|
||||||
|
progress: Int = 0,
|
||||||
|
max: Int = 0,
|
||||||
|
indeterminate: Boolean = false,
|
||||||
|
): NotificationCompat.Builder {
|
||||||
|
var builder = if(isDetail) notificationDetailBuilder else notificationBuilder
|
||||||
|
if (builder == null) {
|
||||||
|
builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
if (isDetail) {
|
||||||
|
notificationDetailBuilder = builder
|
||||||
|
} else {
|
||||||
|
notificationBuilder = builder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (title != null) {
|
||||||
|
builder.setTicker(title).setContentTitle(title)
|
||||||
|
}
|
||||||
|
if (content != null) {
|
||||||
|
builder.setContentText(content)
|
||||||
|
}
|
||||||
|
return builder.setProgress(max, progress, indeterminate)
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun createChannel() {
|
private fun createChannel() {
|
||||||
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
||||||
notificationManager.createNotificationChannel(foreground)
|
notificationManager.createNotificationChannel(foreground)
|
||||||
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT)
|
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
|
||||||
notificationManager.createNotificationChannel(error)
|
notificationManager.createNotificationChannel(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +263,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
private const val NOTIFICATION_ERROR_ID = 2
|
private const val NOTIFICATION_ERROR_ID = 2
|
||||||
|
private const val NOTIFICATION_DETAIL_ID = 3
|
||||||
private const val ONE_MINUTE = 60000L
|
private const val ONE_MINUTE = 60000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,9 +46,6 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
|
|||||||
* @param context Android Context
|
* @param context Android Context
|
||||||
*/
|
*/
|
||||||
fun enable(context: Context, immediate: Boolean = false) {
|
fun enable(context: Context, immediate: Boolean = false) {
|
||||||
// migration to remove any old active background task
|
|
||||||
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
|
|
||||||
|
|
||||||
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
|
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
|
||||||
Log.d(TAG, "enabled ContentObserverWorker")
|
Log.d(TAG, "enabled ContentObserverWorker")
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
@@ -123,8 +120,10 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
|
|||||||
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
|
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startBackupWorker(context: Context, delayMilliseconds: Long) {
|
fun startBackupWorker(context: Context, delayMilliseconds: Long) {
|
||||||
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false))
|
||||||
|
return
|
||||||
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
|
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
|
||||||
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
|
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
|
||||||
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
|
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.work.Configuration
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
|
||||||
|
class ImmichApp : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
val config = Configuration.Builder().build()
|
||||||
|
WorkManager.initialize(this, config)
|
||||||
|
// always start BackupWorker after WorkManager init; this fixes the following bug:
|
||||||
|
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
|
||||||
|
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
||||||
|
// (because of low memory etc.), the backup is never performed.
|
||||||
|
// As a workaround, we also run a backup check when initializing the application
|
||||||
|
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,11 @@ import io.flutter.embedding.engine.FlutterEngine
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
startService(Intent(getBaseContext(), AppClearedService::class.java));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,17 @@
|
|||||||
default_platform(:android)
|
default_platform(:android)
|
||||||
|
|
||||||
platform :android do
|
platform :android do
|
||||||
desc "Build Android"
|
desc "Build Android and Release Testing"
|
||||||
lane :build do
|
lane :beta do
|
||||||
gradle(
|
gradle(
|
||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
|
properties: {
|
||||||
|
"android.injected.version.code" => 47,
|
||||||
|
"android.injected.version.name" => "1.30.2",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Build and Release Android"
|
desc "Build and Release Android"
|
||||||
@@ -30,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 40,
|
"android.injected.version.code" => 52,
|
||||||
"android.injected.version.name" => "1.28.2",
|
"android.injected.version.name" => "1.33.0",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
|||||||
|
|
||||||
## Android
|
## Android
|
||||||
|
|
||||||
### android build
|
### android beta
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
[bundle exec] fastlane android build
|
[bundle exec] fastlane android beta
|
||||||
```
|
```
|
||||||
|
|
||||||
Build Android
|
Build Android and Release Testing
|
||||||
|
|
||||||
### android release
|
### android release
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* Fixed oversize play button on video
|
||||||
|
* Fixed app crashing when swipe between assets
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* Fixed Android BackgroundServiceStartNotAllowedException
|
||||||
|
* Restore old cache mechanism
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Update deprecated API that causes notification not dismissing after background upload progress finished.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Fixed app crashes when there is no object detection result.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Correctly display time based on timezone
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Added improvement for timeline view
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Improve scroll thumb date info
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Fixed parsing date error prevent timeline to be loaded.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* Fixed run background service after being killed
|
||||||
|
* Added background backup progress notifications
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* Integrate new grid system to the main timeline.
|
||||||
|
* Minor UI update.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Improved slow initial loading for large amount of asset.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
* Fixed back button navigation
|
||||||
|
* Added duplicated asset upload handling mechanism
|
||||||
|
* Fixed cannot signout completely when "Save Logged In" is checked
|
||||||
@@ -5,17 +5,17 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.311329">
|
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.070842">
|
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"backup_controller_page_backup_selected": "Ausgewählt: ",
|
"backup_controller_page_backup_selected": "Ausgewählt: ",
|
||||||
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
|
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
|
||||||
"backup_controller_page_cancel": "Abbrechen",
|
"backup_controller_page_cancel": "Abbrechen",
|
||||||
|
"backup_background_service_default_notification": "Suche nach neuen assets…",
|
||||||
"backup_controller_page_created": "Erstellt: {}",
|
"backup_controller_page_created": "Erstellt: {}",
|
||||||
"backup_controller_page_desc_backup": "Aktiviere die Sicherung um Elemente automatisch auf den Server zu laden.",
|
"backup_controller_page_desc_backup": "Aktiviere die Sicherung um Elemente automatisch auf den Server zu laden.",
|
||||||
"backup_controller_page_excluded": "Ausgeschlossen: ",
|
"backup_controller_page_excluded": "Ausgeschlossen: ",
|
||||||
@@ -123,4 +124,4 @@
|
|||||||
"version_announcement_overlay_text_2": "Bitte nehm dir die Zeit und lese das ",
|
"version_announcement_overlay_text_2": "Bitte nehm dir die Zeit und lese das ",
|
||||||
"version_announcement_overlay_text_3": " und achte darauf, dass deine docker-compose und .env Dateien aktuell sind, vor allem wenn du ein System für automatische Updates benutzt (z.B. Watchtower).",
|
"version_announcement_overlay_text_3": " und achte darauf, dass deine docker-compose und .env Dateien aktuell sind, vor allem wenn du ein System für automatische Updates benutzt (z.B. Watchtower).",
|
||||||
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89"
|
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"backup_controller_page_backup_sub": "Backed up photos and videos",
|
"backup_controller_page_backup_sub": "Backed up photos and videos",
|
||||||
"backup_controller_page_cancel": "Cancel",
|
"backup_controller_page_cancel": "Cancel",
|
||||||
"backup_controller_page_created": "Created on: {}",
|
"backup_controller_page_created": "Created on: {}",
|
||||||
"backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.",
|
"backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.",
|
||||||
"backup_controller_page_excluded": "Excluded: ",
|
"backup_controller_page_excluded": "Excluded: ",
|
||||||
"backup_controller_page_failed": "Failed ({})",
|
"backup_controller_page_failed": "Failed ({})",
|
||||||
"backup_controller_page_filename": "File name: {} [{}]",
|
"backup_controller_page_filename": "File name: {} [{}]",
|
||||||
@@ -58,14 +58,14 @@
|
|||||||
"backup_controller_page_select": "Select",
|
"backup_controller_page_select": "Select",
|
||||||
"backup_controller_page_server_storage": "Server Storage",
|
"backup_controller_page_server_storage": "Server Storage",
|
||||||
"backup_controller_page_start_backup": "Start Backup",
|
"backup_controller_page_start_backup": "Start Backup",
|
||||||
"backup_controller_page_status_off": "Backup is off",
|
"backup_controller_page_status_off": "Automatic foreground backup is off",
|
||||||
"backup_controller_page_status_on": "Backup is on",
|
"backup_controller_page_status_on": "Automatic foreground backup is on",
|
||||||
"backup_controller_page_storage_format": "{} of {} used",
|
"backup_controller_page_storage_format": "{} of {} used",
|
||||||
"backup_controller_page_to_backup": "Albums to be backup",
|
"backup_controller_page_to_backup": "Albums to be backup",
|
||||||
"backup_controller_page_total": "Total",
|
"backup_controller_page_total": "Total",
|
||||||
"backup_controller_page_total_sub": "All unique photos and videos from selected albums",
|
"backup_controller_page_total_sub": "All unique photos and videos from selected albums",
|
||||||
"backup_controller_page_turn_off": "Turn off Backup",
|
"backup_controller_page_turn_off": "Turn off foreground backup",
|
||||||
"backup_controller_page_turn_on": "Turn on Backup",
|
"backup_controller_page_turn_on": "Turn on foreground backup",
|
||||||
"backup_controller_page_uploading_file_info": "Uploading file info",
|
"backup_controller_page_uploading_file_info": "Uploading file info",
|
||||||
"backup_err_only_album": "Cannot remove the only album",
|
"backup_err_only_album": "Cannot remove the only album",
|
||||||
"backup_info_card_assets": "assets",
|
"backup_info_card_assets": "assets",
|
||||||
@@ -134,6 +134,10 @@
|
|||||||
"setting_notifications_notify_never": "never",
|
"setting_notifications_notify_never": "never",
|
||||||
"setting_notifications_subtitle": "Adjust your notification preferences",
|
"setting_notifications_subtitle": "Adjust your notification preferences",
|
||||||
"setting_notifications_title": "Notifications",
|
"setting_notifications_title": "Notifications",
|
||||||
|
"setting_notifications_total_progress_title": "Show background backup total progress",
|
||||||
|
"setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)",
|
||||||
|
"setting_notifications_single_progress_title": "Show background backup detail progress",
|
||||||
|
"setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset",
|
||||||
"setting_pages_app_bar_settings": "Settings",
|
"setting_pages_app_bar_settings": "Settings",
|
||||||
"share_add": "Add",
|
"share_add": "Add",
|
||||||
"share_add_photos": "Add photos",
|
"share_add_photos": "Add photos",
|
||||||
@@ -165,5 +169,7 @@
|
|||||||
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
||||||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
||||||
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
|
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||||
}
|
"experimental_settings_title": "Experimental",
|
||||||
|
"experimental_settings_subtitle": "Use at your own risk!"
|
||||||
|
}
|
||||||
|
|||||||
@@ -360,11 +360,11 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -495,11 +495,11 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -522,11 +522,11 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.27.0</string>
|
<string>1.30.1</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>52</string>
|
<string>62</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true />
|
<true />
|
||||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.28.2"
|
version_number: "1.33.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -5,32 +5,32 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.499192">
|
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="30.057077">
|
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.438506">
|
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="4: build_app" time="91.259106">
|
<testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="102.092139">
|
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
|
|||||||
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
|
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
|
||||||
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
|
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
|
||||||
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
|
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
|
||||||
|
|
||||||
|
// Duplicate asset
|
||||||
|
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
|
||||||
|
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
|
||||||
import 'package:immich_mobile/constants/locales.dart';
|
import 'package:immich_mobile/constants/locales.dart';
|
||||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
@@ -31,12 +31,14 @@ void main() async {
|
|||||||
|
|
||||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||||
|
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
|
||||||
|
|
||||||
await Hive.openBox(userInfoBox);
|
await Hive.openBox(userInfoBox);
|
||||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
await Hive.openBox(hiveGithubReleaseInfoBox);
|
await Hive.openBox(hiveGithubReleaseInfoBox);
|
||||||
await Hive.openBox(userSettingInfoBox);
|
await Hive.openBox(userSettingInfoBox);
|
||||||
|
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
|
||||||
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(
|
const SystemUiOverlayStyle(
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
||||||
AlbumNotifier(this._albumService) : super([]);
|
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
|
final AlbumCacheService _albumCacheService;
|
||||||
|
|
||||||
|
_cacheState() {
|
||||||
|
_albumCacheService.put(state);
|
||||||
|
}
|
||||||
|
|
||||||
getAllAlbums() async {
|
getAllAlbums() async {
|
||||||
|
|
||||||
|
if (await _albumCacheService.isValid() && state.isEmpty) {
|
||||||
|
state = await _albumCacheService.get();
|
||||||
|
}
|
||||||
|
|
||||||
List<AlbumResponseDto>? albums =
|
List<AlbumResponseDto>? albums =
|
||||||
await _albumService.getAlbums(isShared: false);
|
await _albumService.getAlbums(isShared: false);
|
||||||
|
|
||||||
if (albums != null) {
|
if (albums != null) {
|
||||||
state = albums;
|
state = albums;
|
||||||
|
_cacheState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAlbum(String albumId) {
|
deleteAlbum(String albumId) {
|
||||||
state = state.where((album) => album.id != albumId).toList();
|
state = state.where((album) => album.id != albumId).toList();
|
||||||
|
_cacheState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AlbumResponseDto?> createAlbum(
|
Future<AlbumResponseDto?> createAlbum(
|
||||||
@@ -28,6 +41,8 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
|||||||
|
|
||||||
if (album != null) {
|
if (album != null) {
|
||||||
state = [...state, album];
|
state = [...state, album];
|
||||||
|
_cacheState();
|
||||||
|
|
||||||
return album;
|
return album;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -36,5 +51,8 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
|||||||
|
|
||||||
final albumProvider =
|
final albumProvider =
|
||||||
StateNotifierProvider<AlbumNotifier, List<AlbumResponseDto>>((ref) {
|
StateNotifierProvider<AlbumNotifier, List<AlbumResponseDto>>((ref) {
|
||||||
return AlbumNotifier(ref.watch(albumServiceProvider));
|
return AlbumNotifier(
|
||||||
|
ref.watch(albumServiceProvider),
|
||||||
|
ref.watch(albumCacheServiceProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
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/modules/album/services/album.service.dart';
|
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
||||||
SharedAlbumNotifier(this._sharedAlbumService) : super([]);
|
SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]);
|
||||||
|
|
||||||
final AlbumService _sharedAlbumService;
|
final AlbumService _sharedAlbumService;
|
||||||
|
final SharedAlbumCacheService _sharedAlbumCacheService;
|
||||||
|
|
||||||
|
_cacheState() {
|
||||||
|
_sharedAlbumCacheService.put(state);
|
||||||
|
}
|
||||||
|
|
||||||
Future<AlbumResponseDto?> createSharedAlbum(
|
Future<AlbumResponseDto?> createSharedAlbum(
|
||||||
String albumName,
|
String albumName,
|
||||||
@@ -22,6 +28,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
|||||||
|
|
||||||
if (newAlbum != null) {
|
if (newAlbum != null) {
|
||||||
state = [...state, newAlbum];
|
state = [...state, newAlbum];
|
||||||
|
_cacheState();
|
||||||
}
|
}
|
||||||
|
|
||||||
return newAlbum;
|
return newAlbum;
|
||||||
@@ -33,16 +40,22 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAllSharedAlbums() async {
|
getAllSharedAlbums() async {
|
||||||
|
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
|
||||||
|
state = await _sharedAlbumCacheService.get();
|
||||||
|
}
|
||||||
|
|
||||||
List<AlbumResponseDto>? sharedAlbums =
|
List<AlbumResponseDto>? sharedAlbums =
|
||||||
await _sharedAlbumService.getAlbums(isShared: true);
|
await _sharedAlbumService.getAlbums(isShared: true);
|
||||||
|
|
||||||
if (sharedAlbums != null) {
|
if (sharedAlbums != null) {
|
||||||
state = sharedAlbums;
|
state = sharedAlbums;
|
||||||
|
_cacheState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAlbum(String albumId) async {
|
deleteAlbum(String albumId) async {
|
||||||
state = state.where((album) => album.id != albumId).toList();
|
state = state.where((album) => album.id != albumId).toList();
|
||||||
|
_cacheState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> leaveAlbum(String albumId) async {
|
Future<bool> leaveAlbum(String albumId) async {
|
||||||
@@ -50,6 +63,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
|||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
state = state.where((album) => album.id != albumId).toList();
|
state = state.where((album) => album.id != albumId).toList();
|
||||||
|
_cacheState();
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
@@ -72,7 +86,10 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
|||||||
|
|
||||||
final sharedAlbumProvider =
|
final sharedAlbumProvider =
|
||||||
StateNotifierProvider<SharedAlbumNotifier, List<AlbumResponseDto>>((ref) {
|
StateNotifierProvider<SharedAlbumNotifier, List<AlbumResponseDto>>((ref) {
|
||||||
return SharedAlbumNotifier(ref.watch(albumServiceProvider));
|
return SharedAlbumNotifier(
|
||||||
|
ref.watch(albumServiceProvider),
|
||||||
|
ref.watch(sharedAlbumCacheServiceProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final sharedAlbumDetailProvider = FutureProvider.autoDispose
|
final sharedAlbumDetailProvider = FutureProvider.autoDispose
|
||||||
|
|||||||
49
mobile/lib/modules/album/services/album_cache.service.dart
Normal file
49
mobile/lib/modules/album/services/album_cache.service.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/json_cache.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
class BaseAlbumCacheService extends JsonCache<List<AlbumResponseDto>> {
|
||||||
|
BaseAlbumCacheService(super.cacheFileName);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void put(List<AlbumResponseDto> data) {
|
||||||
|
putRawData(data.map((e) => e.toJson()).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AlbumResponseDto>> get() async {
|
||||||
|
try {
|
||||||
|
final mapList = await readRawData() as List<dynamic>;
|
||||||
|
|
||||||
|
final responseData = mapList
|
||||||
|
.map((e) => AlbumResponseDto.fromJson(e))
|
||||||
|
.whereNotNull()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return responseData;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumCacheService extends BaseAlbumCacheService {
|
||||||
|
AlbumCacheService() : super("album_cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
class SharedAlbumCacheService extends BaseAlbumCacheService {
|
||||||
|
SharedAlbumCacheService() : super("shared_album_cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
final albumCacheServiceProvider = Provider(
|
||||||
|
(ref) => AlbumCacheService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final sharedAlbumCacheServiceProvider = Provider(
|
||||||
|
(ref) => SharedAlbumCacheService(),
|
||||||
|
);
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -15,11 +14,9 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
const AlbumThumbnailCard({
|
const AlbumThumbnailCard({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.album,
|
required this.album,
|
||||||
required this.cacheService,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final AlbumResponseDto album;
|
final AlbumResponseDto album;
|
||||||
final CacheService cacheService;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -39,7 +36,6 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
|
|
||||||
memCacheHeight: max(400, cardSize.toInt() * 3),
|
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||||
width: cardSize,
|
width: cardSize,
|
||||||
height: cardSize,
|
height: cardSize,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
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/immich_colors.dart';
|
|
||||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -17,13 +14,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
final AssetResponseDto asset;
|
final AssetResponseDto asset;
|
||||||
final List<AssetResponseDto> assetList;
|
final List<AssetResponseDto> assetList;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
final BaseCacheManager? cacheManager;
|
|
||||||
|
|
||||||
const AlbumViewerThumbnail({
|
const AlbumViewerThumbnail({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.assetList,
|
required this.assetList,
|
||||||
this.cacheManager,
|
|
||||||
this.showStorageIndicator = true,
|
this.showStorageIndicator = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@@ -126,7 +121,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(border: drawBorderColor()),
|
decoration: BoxDecoration(border: drawBorderColor()),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
cacheManager: cacheManager,
|
|
||||||
cacheKey: asset.id,
|
cacheKey: asset.id,
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 300,
|
height: 300,
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -24,7 +22,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
|||||||
var newAssetsForAlbum =
|
var newAssetsForAlbum =
|
||||||
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||||
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||||
final cacheService = ref.watch(cacheServiceProvider);
|
|
||||||
|
|
||||||
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
||||||
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
|
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
|
||||||
@@ -114,7 +111,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(border: drawBorderColor()),
|
decoration: BoxDecoration(border: drawBorderColor()),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
|
||||||
cacheKey: asset.id,
|
cacheKey: asset.id,
|
||||||
width: 150,
|
width: 150,
|
||||||
height: 150,
|
height: 150,
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -16,7 +14,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final cacheService = ref.watch(cacheServiceProvider);
|
|
||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -26,7 +23,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
|
||||||
cacheKey: asset.id,
|
cacheKey: asset.id,
|
||||||
width: 500,
|
width: 500,
|
||||||
height: 500,
|
height: 500,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
|
|||||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||||
@@ -192,7 +191,6 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
final bool showStorageIndicator =
|
final bool showStorageIndicator =
|
||||||
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||||
final cacheService = ref.watch(cacheServiceProvider);
|
|
||||||
|
|
||||||
if (albumInfo.assets.isNotEmpty) {
|
if (albumInfo.assets.isNotEmpty) {
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
@@ -207,7 +205,6 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) {
|
(BuildContext context, int index) {
|
||||||
return AlbumViewerThumbnail(
|
return AlbumViewerThumbnail(
|
||||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
|
||||||
asset: albumInfo.assets[index],
|
asset: albumInfo.assets[index],
|
||||||
assetList: albumInfo.assets,
|
assetList: albumInfo.assets,
|
||||||
showStorageIndicator: showStorageIndicator,
|
showStorageIndicator: showStorageIndicator,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
|
|
||||||
class LibraryPage extends HookConsumerWidget {
|
class LibraryPage extends HookConsumerWidget {
|
||||||
const LibraryPage({Key? key}) : super(key: key);
|
const LibraryPage({Key? key}) : super(key: key);
|
||||||
@@ -14,7 +13,6 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final albums = ref.watch(albumProvider);
|
final albums = ref.watch(albumProvider);
|
||||||
final cacheService = ref.watch(cacheServiceProvider);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -104,7 +102,6 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
_buildCreateAlbumButton(),
|
_buildCreateAlbumButton(),
|
||||||
for (var album in albums)
|
for (var album in albums)
|
||||||
AlbumThumbnailCard(
|
AlbumThumbnailCard(
|
||||||
cacheService: cacheService,
|
|
||||||
album: album,
|
album: album,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
primary: Theme.of(context).primaryColor,
|
foregroundColor: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
onPressed:
|
onPressed:
|
||||||
sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
|
sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
|||||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||||
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -47,8 +45,6 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
height: 60,
|
height: 60,
|
||||||
memCacheHeight: 200,
|
memCacheHeight: 200,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
cacheManager:
|
|
||||||
cacheService.getCache(CacheType.sharedAlbumThumbnail),
|
|
||||||
imageUrl: getAlbumThumbnailUrl(album),
|
imageUrl: getAlbumThumbnailUrl(album),
|
||||||
cacheKey: album.albumThumbnailAssetId,
|
cacheKey: album.albumThumbnailAssetId,
|
||||||
httpHeaders: {
|
httpHeaders: {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
|||||||
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
||||||
Text(
|
Text(
|
||||||
DateFormat('date_format'.tr()).format(
|
DateFormat('date_format'.tr()).format(
|
||||||
assetDetail.exifInfo!.dateTimeOriginal!,
|
assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey[400],
|
color: Colors.grey[400],
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
|
||||||
enum _RemoteImageStatus { empty, thumbnail, preview, full }
|
enum _RemoteImageStatus { empty, thumbnail, preview, full }
|
||||||
@@ -12,6 +10,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
bool _zoomedIn = false;
|
bool _zoomedIn = false;
|
||||||
|
|
||||||
static const int swipeThreshold = 100;
|
static const int swipeThreshold = 100;
|
||||||
|
late CachedNetworkImageProvider fullProvider;
|
||||||
|
late CachedNetworkImageProvider previewProvider;
|
||||||
|
late CachedNetworkImageProvider thumbnailProvider;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -56,21 +57,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
widget.isZoomedFunction();
|
widget.isZoomedFunction();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fireStartLoadingEvent() {
|
|
||||||
widget.onLoadingStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _fireFinishedLoadingEvent() {
|
|
||||||
widget.onLoadingCompleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
CachedNetworkImageProvider _authorizedImageProvider(
|
CachedNetworkImageProvider _authorizedImageProvider(
|
||||||
String url, String cacheKey, BaseCacheManager? cacheManager) {
|
String url,
|
||||||
|
String cacheKey,
|
||||||
|
) {
|
||||||
return CachedNetworkImageProvider(
|
return CachedNetworkImageProvider(
|
||||||
url,
|
url,
|
||||||
headers: {"Authorization": widget.authToken},
|
headers: {"Authorization": widget.authToken},
|
||||||
cacheKey: cacheKey,
|
cacheKey: cacheKey,
|
||||||
cacheManager: cacheManager,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (newStatus != _RemoteImageStatus.full) {
|
|
||||||
_fireStartLoadingEvent();
|
|
||||||
} else {
|
|
||||||
_fireFinishedLoadingEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_status = newStatus;
|
_status = newStatus;
|
||||||
_imageProvider = provider;
|
_imageProvider = provider;
|
||||||
@@ -104,10 +92,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _loadImages() {
|
void _loadImages() {
|
||||||
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
|
thumbnailProvider = _authorizedImageProvider(
|
||||||
widget.thumbnailUrl,
|
widget.thumbnailUrl,
|
||||||
widget.cacheKey,
|
widget.cacheKey,
|
||||||
widget.thumbnailCacheManager,
|
|
||||||
);
|
);
|
||||||
_imageProvider = thumbnailProvider;
|
_imageProvider = thumbnailProvider;
|
||||||
|
|
||||||
@@ -121,10 +108,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (widget.previewUrl != null) {
|
if (widget.previewUrl != null) {
|
||||||
CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
|
previewProvider = _authorizedImageProvider(
|
||||||
widget.previewUrl!,
|
widget.previewUrl!,
|
||||||
"${widget.cacheKey}_previewStage",
|
"${widget.cacheKey}_previewStage",
|
||||||
widget.previewCacheManager,
|
|
||||||
);
|
);
|
||||||
previewProvider.resolve(const ImageConfiguration()).addListener(
|
previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||||
@@ -133,10 +119,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
|
fullProvider = _authorizedImageProvider(
|
||||||
widget.imageUrl,
|
widget.imageUrl,
|
||||||
"${widget.cacheKey}_fullStage",
|
"${widget.cacheKey}_fullStage",
|
||||||
widget.fullCacheManager,
|
|
||||||
);
|
);
|
||||||
fullProvider.resolve(const ImageConfiguration()).addListener(
|
fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||||
@@ -147,8 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
_loadImages();
|
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() async {
|
||||||
|
super.dispose();
|
||||||
|
|
||||||
|
if (_status == _RemoteImageStatus.full) {
|
||||||
|
await fullProvider.evict();
|
||||||
|
} else if (_status == _RemoteImageStatus.preview) {
|
||||||
|
await previewProvider.evict();
|
||||||
|
} else if (_status == _RemoteImageStatus.thumbnail) {
|
||||||
|
await thumbnailProvider.evict();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _imageProvider.evict();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +163,6 @@ class RemotePhotoView extends StatefulWidget {
|
|||||||
required this.onSwipeDown,
|
required this.onSwipeDown,
|
||||||
required this.onSwipeUp,
|
required this.onSwipeUp,
|
||||||
this.previewUrl,
|
this.previewUrl,
|
||||||
required this.onLoadingCompleted,
|
|
||||||
required this.onLoadingStart,
|
|
||||||
this.thumbnailCacheManager,
|
|
||||||
this.previewCacheManager,
|
|
||||||
this.fullCacheManager,
|
|
||||||
required this.cacheKey,
|
required this.cacheKey,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@@ -175,11 +170,6 @@ class RemotePhotoView extends StatefulWidget {
|
|||||||
final String imageUrl;
|
final String imageUrl;
|
||||||
final String authToken;
|
final String authToken;
|
||||||
final String? previewUrl;
|
final String? previewUrl;
|
||||||
final Function onLoadingCompleted;
|
|
||||||
final Function onLoadingStart;
|
|
||||||
final BaseCacheManager? thumbnailCacheManager;
|
|
||||||
final BaseCacheManager? previewCacheManager;
|
|
||||||
final BaseCacheManager? fullCacheManager;
|
|
||||||
final String cacheKey;
|
final String cacheKey;
|
||||||
|
|
||||||
final void Function() onSwipeDown;
|
final void Function() onSwipeDown;
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
|||||||
double iconSize = 18.0;
|
double iconSize = 18.0;
|
||||||
|
|
||||||
return AppBar(
|
return AppBar(
|
||||||
// iconTheme: IconThemeData(color: Colors.grey[100]),
|
|
||||||
// actionsIconTheme: IconThemeData(color: Colors.grey[100]),
|
|
||||||
foregroundColor: Colors.grey[100],
|
foregroundColor: Colors.grey[100],
|
||||||
toolbarHeight: 60,
|
toolbarHeight: 60,
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.transparent,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AutoRouter.of(context).pop();
|
AutoRouter.of(context).pop();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
|
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
@@ -111,6 +112,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
: const BouncingScrollPhysics(),
|
: const BouncingScrollPhysics(),
|
||||||
itemCount: assetList.length,
|
itemCount: assetList.length,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
|
onPageChanged: (value) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
initState(index);
|
initState(index);
|
||||||
|
|
||||||
@@ -121,8 +125,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||||
isZoomedFunction: isZoomedMethod,
|
isZoomedFunction: isZoomedMethod,
|
||||||
isZoomedListener: isZoomedListener,
|
isZoomedListener: isZoomedListener,
|
||||||
onLoadingCompleted: () => {},
|
|
||||||
onLoadingStart: () => {},
|
|
||||||
asset: assetList[index],
|
asset: assetList[index],
|
||||||
heroTag: assetList[index].id,
|
heroTag: assetList[index].id,
|
||||||
threeStageLoading: threeStageLoading.value,
|
threeStageLoading: threeStageLoading.value,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
|
|||||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
|
||||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -19,8 +18,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
final String authToken;
|
final String authToken;
|
||||||
final ValueNotifier<bool> isZoomedListener;
|
final ValueNotifier<bool> isZoomedListener;
|
||||||
final void Function() isZoomedFunction;
|
final void Function() isZoomedFunction;
|
||||||
final void Function() onLoadingCompleted;
|
|
||||||
final void Function() onLoadingStart;
|
|
||||||
final bool threeStageLoading;
|
final bool threeStageLoading;
|
||||||
|
|
||||||
ImageViewerPage({
|
ImageViewerPage({
|
||||||
@@ -30,8 +27,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
required this.authToken,
|
required this.authToken,
|
||||||
required this.isZoomedFunction,
|
required this.isZoomedFunction,
|
||||||
required this.isZoomedListener,
|
required this.isZoomedListener,
|
||||||
required this.onLoadingCompleted,
|
|
||||||
required this.onLoadingStart,
|
|
||||||
required this.threeStageLoading,
|
required this.threeStageLoading,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@@ -41,7 +36,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final downloadAssetStatus =
|
final downloadAssetStatus =
|
||||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||||
final cacheService = ref.watch(cacheServiceProvider);
|
|
||||||
|
|
||||||
getAssetExif() async {
|
getAssetExif() async {
|
||||||
assetDetail =
|
assetDetail =
|
||||||
@@ -85,14 +79,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
isZoomedListener: isZoomedListener,
|
isZoomedListener: isZoomedListener,
|
||||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||||
onSwipeUp: () => showInfo(),
|
onSwipeUp: () => showInfo(),
|
||||||
onLoadingCompleted: onLoadingCompleted,
|
|
||||||
onLoadingStart: onLoadingStart,
|
|
||||||
thumbnailCacheManager:
|
|
||||||
cacheService.getCache(CacheType.thumbnail),
|
|
||||||
previewCacheManager:
|
|
||||||
cacheService.getCache(CacheType.imageViewerPreview),
|
|
||||||
fullCacheManager:
|
|
||||||
cacheService.getCache(CacheType.imageViewerFull),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
|||||||
_createChewieController() {
|
_createChewieController() {
|
||||||
chewieController = ChewieController(
|
chewieController = ChewieController(
|
||||||
showOptions: true,
|
showOptions: true,
|
||||||
showControlsOnInitialize: true,
|
showControlsOnInitialize: false,
|
||||||
videoPlayerController: videoPlayerController,
|
videoPlayerController: videoPlayerController,
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
autoInitialize: true,
|
autoInitialize: true,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/backup/background_service/localization.dar
|
|||||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
@@ -27,11 +28,11 @@ final backgroundServiceProvider = Provider(
|
|||||||
/// Background backup service
|
/// Background backup service
|
||||||
class BackgroundService {
|
class BackgroundService {
|
||||||
static const String _portNameLock = "immichLock";
|
static const String _portNameLock = "immichLock";
|
||||||
BackgroundService();
|
|
||||||
static const MethodChannel _foregroundChannel =
|
static const MethodChannel _foregroundChannel =
|
||||||
MethodChannel('immich/foregroundChannel');
|
MethodChannel('immich/foregroundChannel');
|
||||||
static const MethodChannel _backgroundChannel =
|
static const MethodChannel _backgroundChannel =
|
||||||
MethodChannel('immich/backgroundChannel');
|
MethodChannel('immich/backgroundChannel');
|
||||||
|
static final NumberFormat numberFormat = NumberFormat("###0.##");
|
||||||
bool _isBackgroundInitialized = false;
|
bool _isBackgroundInitialized = false;
|
||||||
CancellationToken? _cancellationToken;
|
CancellationToken? _cancellationToken;
|
||||||
bool _canceledBySystem = false;
|
bool _canceledBySystem = false;
|
||||||
@@ -40,6 +41,10 @@ class BackgroundService {
|
|||||||
SendPort? _waitingIsolate;
|
SendPort? _waitingIsolate;
|
||||||
ReceivePort? _rp;
|
ReceivePort? _rp;
|
||||||
bool _errorGracePeriodExceeded = true;
|
bool _errorGracePeriodExceeded = true;
|
||||||
|
int _uploadedAssetsCount = 0;
|
||||||
|
int _assetsToUploadCount = 0;
|
||||||
|
int _lastDetailProgressUpdate = 0;
|
||||||
|
String _lastPrintedProgress = "";
|
||||||
|
|
||||||
bool get isBackgroundInitialized {
|
bool get isBackgroundInitialized {
|
||||||
return _isBackgroundInitialized;
|
return _isBackgroundInitialized;
|
||||||
@@ -125,22 +130,29 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the notification shown by the background service
|
/// Updates the notification shown by the background service
|
||||||
Future<bool> _updateNotification({
|
Future<bool?> _updateNotification({
|
||||||
required String title,
|
String? title,
|
||||||
String? content,
|
String? content,
|
||||||
|
int progress = 0,
|
||||||
|
int max = 0,
|
||||||
|
bool indeterminate = false,
|
||||||
|
bool isDetail = false,
|
||||||
|
bool onlyIfFG = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (_isBackgroundInitialized) {
|
if (_isBackgroundInitialized) {
|
||||||
return await _backgroundChannel
|
return _backgroundChannel.invokeMethod<bool>(
|
||||||
.invokeMethod('updateNotification', [title, content]);
|
'updateNotification',
|
||||||
|
[title, content, progress, max, indeterminate, isDetail, onlyIfFG],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("[_updateNotification] failed to communicate with plugin");
|
debugPrint("[_updateNotification] failed to communicate with plugin");
|
||||||
}
|
}
|
||||||
return Future.value(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a new priority notification
|
/// Shows a new priority notification
|
||||||
@@ -274,6 +286,7 @@ class BackgroundService {
|
|||||||
case "onAssetsChanged":
|
case "onAssetsChanged":
|
||||||
final Future<bool> translationsLoaded = loadTranslations();
|
final Future<bool> translationsLoaded = loadTranslations();
|
||||||
try {
|
try {
|
||||||
|
_clearErrorNotifications();
|
||||||
final bool hasAccess = await acquireLock();
|
final bool hasAccess = await acquireLock();
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
debugPrint("[_callHandler] could not acquire lock, exiting");
|
debugPrint("[_callHandler] could not acquire lock, exiting");
|
||||||
@@ -304,28 +317,35 @@ class BackgroundService {
|
|||||||
|
|
||||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||||
|
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
|
||||||
|
|
||||||
await Hive.openBox(userInfoBox);
|
await Hive.openBox(userInfoBox);
|
||||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||||
await Hive.openBox(userSettingInfoBox);
|
await Hive.openBox(userSettingInfoBox);
|
||||||
await Hive.openBox(backgroundBackupInfoBox);
|
await Hive.openBox(backgroundBackupInfoBox);
|
||||||
|
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
|
||||||
|
|
||||||
ApiService apiService = ApiService();
|
ApiService apiService = ApiService();
|
||||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||||
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
||||||
BackupService backupService = BackupService(apiService);
|
BackupService backupService = BackupService(apiService);
|
||||||
|
AppSettingsService settingsService = AppSettingsService();
|
||||||
|
|
||||||
final Box<HiveBackupAlbums> box =
|
final Box<HiveBackupAlbums> box =
|
||||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||||
if (backupAlbumInfo == null) {
|
if (backupAlbumInfo == null) {
|
||||||
_clearErrorNotifications();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await PhotoManager.setIgnorePermissionCheck(true);
|
await PhotoManager.setIgnorePermissionCheck(true);
|
||||||
|
|
||||||
do {
|
do {
|
||||||
final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
|
final bool backupOk = await _runBackup(
|
||||||
|
backupService,
|
||||||
|
settingsService,
|
||||||
|
backupAlbumInfo,
|
||||||
|
);
|
||||||
if (backupOk) {
|
if (backupOk) {
|
||||||
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
||||||
await box.put(
|
await box.put(
|
||||||
@@ -346,9 +366,14 @@ class BackgroundService {
|
|||||||
|
|
||||||
Future<bool> _runBackup(
|
Future<bool> _runBackup(
|
||||||
BackupService backupService,
|
BackupService backupService,
|
||||||
|
AppSettingsService settingsService,
|
||||||
HiveBackupAlbums backupAlbumInfo,
|
HiveBackupAlbums backupAlbumInfo,
|
||||||
) async {
|
) async {
|
||||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
|
||||||
|
final bool notifyTotalProgress = settingsService
|
||||||
|
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
|
||||||
|
final bool notifySingleProgress = settingsService
|
||||||
|
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||||
|
|
||||||
if (_canceledBySystem) {
|
if (_canceledBySystem) {
|
||||||
return false;
|
return false;
|
||||||
@@ -372,22 +397,29 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (toUpload.isEmpty) {
|
if (toUpload.isEmpty) {
|
||||||
_clearErrorNotifications();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
_assetsToUploadCount = toUpload.length;
|
||||||
|
_uploadedAssetsCount = 0;
|
||||||
|
_updateNotification(
|
||||||
|
title: "backup_background_service_in_progress_notification".tr(),
|
||||||
|
content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
|
||||||
|
progress: 0,
|
||||||
|
max: notifyTotalProgress ? _assetsToUploadCount : 0,
|
||||||
|
indeterminate: !notifyTotalProgress,
|
||||||
|
onlyIfFG: !notifyTotalProgress,
|
||||||
|
);
|
||||||
|
|
||||||
_cancellationToken = CancellationToken();
|
_cancellationToken = CancellationToken();
|
||||||
final bool ok = await backupService.backupAsset(
|
final bool ok = await backupService.backupAsset(
|
||||||
toUpload,
|
toUpload,
|
||||||
_cancellationToken!,
|
_cancellationToken!,
|
||||||
_onAssetUploaded,
|
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
|
||||||
_onProgress,
|
notifySingleProgress ? _onProgress : (sent, total) {},
|
||||||
_onSetCurrentBackupAsset,
|
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
|
||||||
_onBackupError,
|
_onBackupError,
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (!ok && !_cancellationToken!.isCancelled) {
|
||||||
_clearErrorNotifications();
|
|
||||||
} else {
|
|
||||||
_showErrorNotification(
|
_showErrorNotification(
|
||||||
title: "backup_background_service_error_title".tr(),
|
title: "backup_background_service_error_title".tr(),
|
||||||
content: "backup_background_service_backup_failed_message".tr(),
|
content: "backup_background_service_backup_failed_message".tr(),
|
||||||
@@ -396,16 +428,43 @@ class BackgroundService {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
String _formatAssetBackupProgress() {
|
||||||
debugPrint("Uploaded $deviceAssetId from $deviceId");
|
final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
|
||||||
|
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onProgress(int sent, int total) {}
|
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
|
||||||
|
debugPrint("Uploaded $deviceAssetId from $deviceId");
|
||||||
|
_uploadedAssetsCount++;
|
||||||
|
_updateNotification(
|
||||||
|
progress: _uploadedAssetsCount,
|
||||||
|
max: _assetsToUploadCount,
|
||||||
|
content: _formatAssetBackupProgress(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onProgress(int sent, int total) {
|
||||||
|
final int now = Timeline.now;
|
||||||
|
// limit updates to 10 per second (or Android drops important notifications)
|
||||||
|
if (now > _lastDetailProgressUpdate + 100000) {
|
||||||
|
final String msg = _humanReadableBytesProgress(sent, total);
|
||||||
|
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
|
||||||
|
if (msg != _lastPrintedProgress) {
|
||||||
|
_lastDetailProgressUpdate = now;
|
||||||
|
_lastPrintedProgress = msg;
|
||||||
|
_updateNotification(
|
||||||
|
progress: sent,
|
||||||
|
max: total,
|
||||||
|
isDetail: true,
|
||||||
|
content: msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||||
_showErrorNotification(
|
_showErrorNotification(
|
||||||
title: "Upload failed",
|
title: "backup_background_service_upload_failure_notification"
|
||||||
content: "backup_background_service_upload_failure_notification"
|
|
||||||
.tr(args: [errorAssetInfo.fileName]),
|
.tr(args: [errorAssetInfo.fileName]),
|
||||||
individualTag: errorAssetInfo.id,
|
individualTag: errorAssetInfo.id,
|
||||||
);
|
);
|
||||||
@@ -413,14 +472,17 @@ class BackgroundService {
|
|||||||
|
|
||||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||||
_updateNotification(
|
_updateNotification(
|
||||||
title: "backup_background_service_in_progress_notification".tr(),
|
title: "backup_background_service_current_upload_notification"
|
||||||
content: "backup_background_service_current_upload_notification"
|
|
||||||
.tr(args: [currentUploadAsset.fileName]),
|
.tr(args: [currentUploadAsset.fileName]),
|
||||||
|
content: "",
|
||||||
|
isDetail: true,
|
||||||
|
progress: 0,
|
||||||
|
max: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isErrorGracePeriodExceeded() {
|
bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
|
||||||
final int value = AppSettingsService()
|
final int value = appSettingsService
|
||||||
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||||
if (value == 0) {
|
if (value == 0) {
|
||||||
return true;
|
return true;
|
||||||
@@ -445,6 +507,26 @@ class BackgroundService {
|
|||||||
assert(false, "Invalid value");
|
assert(false, "Invalid value");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
|
||||||
|
static String _humanReadableBytesProgress(int bytes, int bytesTotal) {
|
||||||
|
String unit = "KB"; // Kilobyte
|
||||||
|
if (bytesTotal >= 0x40000000) {
|
||||||
|
unit = "GB"; // Gigabyte
|
||||||
|
bytes >>= 20;
|
||||||
|
bytesTotal >>= 20;
|
||||||
|
} else if (bytesTotal >= 0x100000) {
|
||||||
|
unit = "MB"; // Megabyte
|
||||||
|
bytes >>= 10;
|
||||||
|
bytesTotal >>= 10;
|
||||||
|
} else if (bytesTotal < 0x400) {
|
||||||
|
return "$bytes / $bytesTotal B";
|
||||||
|
}
|
||||||
|
final int percent = (bytes * 100) ~/ bytesTotal;
|
||||||
|
final String done = numberFormat.format(bytes / 1024.0);
|
||||||
|
final String total = numberFormat.format(bytesTotal / 1024.0);
|
||||||
|
return "$percent% ($done/$total$unit)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ Future<bool> loadTranslations() async {
|
|||||||
|
|
||||||
await controller.loadTranslations();
|
await controller.loadTranslations();
|
||||||
|
|
||||||
return Localization.load(controller.locale,
|
return Localization.load(
|
||||||
translations: controller.translations,
|
controller.locale,
|
||||||
fallbackTranslations: controller.fallbackTranslations);
|
translations: controller.translations,
|
||||||
|
fallbackTranslations: controller.fallbackTranslations,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class AvailableAlbum {
|
|||||||
|
|
||||||
String get name => albumEntity.name;
|
String get name => albumEntity.name;
|
||||||
|
|
||||||
int get assetCount => albumEntity.assetCount;
|
Future<int> get assetCount => albumEntity.assetCountAsync;
|
||||||
|
|
||||||
String get id => albumEntity.id;
|
String get id => albumEntity.id;
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ class ErrorUploadAsset extends Equatable {
|
|||||||
List<Object> get props {
|
List<Object> get props {
|
||||||
return [
|
return [
|
||||||
id,
|
id,
|
||||||
|
createdAt,
|
||||||
fileName,
|
fileName,
|
||||||
fileType,
|
fileType,
|
||||||
|
asset,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:hive/hive.dart';
|
||||||
|
|
||||||
|
part 'hive_duplicated_assets.model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: 2)
|
||||||
|
class HiveDuplicatedAssets {
|
||||||
|
@HiveField(0, defaultValue: [])
|
||||||
|
List<String> duplicatedAssetIds;
|
||||||
|
|
||||||
|
HiveDuplicatedAssets({
|
||||||
|
required this.duplicatedAssetIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
HiveDuplicatedAssets copyWith({
|
||||||
|
List<String>? duplicatedAssetIds,
|
||||||
|
}) {
|
||||||
|
return HiveDuplicatedAssets(
|
||||||
|
duplicatedAssetIds: duplicatedAssetIds ?? this.duplicatedAssetIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'duplicatedAssetIds': duplicatedAssetIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory HiveDuplicatedAssets.fromMap(Map<String, dynamic> map) {
|
||||||
|
return HiveDuplicatedAssets(
|
||||||
|
duplicatedAssetIds: List<String>.from(map['duplicatedAssetIds']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory HiveDuplicatedAssets.fromJson(String source) =>
|
||||||
|
HiveDuplicatedAssets.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'HiveDuplicatedAssets(duplicatedAssetIds: $duplicatedAssetIds)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
final listEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
|
return other is HiveDuplicatedAssets &&
|
||||||
|
listEquals(other.duplicatedAssetIds, duplicatedAssetIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => duplicatedAssetIds.hashCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'hive_duplicated_assets.model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class HiveDuplicatedAssetsAdapter extends TypeAdapter<HiveDuplicatedAssets> {
|
||||||
|
@override
|
||||||
|
final int typeId = 2;
|
||||||
|
|
||||||
|
@override
|
||||||
|
HiveDuplicatedAssets read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return HiveDuplicatedAssets(
|
||||||
|
duplicatedAssetIds:
|
||||||
|
fields[0] == null ? [] : (fields[0] as List).cast<String>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, HiveDuplicatedAssets obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(1)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.duplicatedAssetIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is HiveDuplicatedAssetsAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
|||||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
@@ -183,17 +184,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
for (AssetPathEntity album in albums) {
|
for (AssetPathEntity album in albums) {
|
||||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||||
|
|
||||||
var assetList =
|
var assetCountInAlbum = await album.assetCountAsync;
|
||||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
if (assetCountInAlbum > 0) {
|
||||||
|
var assetList =
|
||||||
|
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
|
||||||
|
|
||||||
if (assetList.isNotEmpty) {
|
if (assetList.isNotEmpty) {
|
||||||
var thumbnailAsset = assetList.first;
|
var thumbnailAsset = assetList.first;
|
||||||
var thumbnailData = await thumbnailAsset
|
var thumbnailData = await thumbnailAsset
|
||||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||||
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
|
availableAlbum =
|
||||||
|
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||||
|
}
|
||||||
|
|
||||||
|
availableAlbums.add(availableAlbum);
|
||||||
}
|
}
|
||||||
|
|
||||||
availableAlbums.add(availableAlbum);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(availableAlbums: availableAlbums);
|
state = state.copyWith(availableAlbums: availableAlbums);
|
||||||
@@ -292,18 +297,23 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
/// Those assets are unique and are used as the total assets
|
/// Those assets are unique and are used as the total assets
|
||||||
///
|
///
|
||||||
Future<void> _updateBackupAssetCount() async {
|
Future<void> _updateBackupAssetCount() async {
|
||||||
|
Set<String> duplicatedAssetIds = _backupService.getDuplicatedAssetIds();
|
||||||
Set<AssetEntity> assetsFromSelectedAlbums = {};
|
Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||||
|
|
||||||
for (var album in state.selectedBackupAlbums) {
|
for (var album in state.selectedBackupAlbums) {
|
||||||
var assets = await album.albumEntity
|
var assets = await album.albumEntity.getAssetListRange(
|
||||||
.getAssetListRange(start: 0, end: album.assetCount);
|
start: 0,
|
||||||
|
end: await album.albumEntity.assetCountAsync,
|
||||||
|
);
|
||||||
assetsFromSelectedAlbums.addAll(assets);
|
assetsFromSelectedAlbums.addAll(assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var album in state.excludedBackupAlbums) {
|
for (var album in state.excludedBackupAlbums) {
|
||||||
var assets = await album.albumEntity
|
var assets = await album.albumEntity.getAssetListRange(
|
||||||
.getAssetListRange(start: 0, end: album.assetCount);
|
start: 0,
|
||||||
|
end: await album.albumEntity.assetCountAsync,
|
||||||
|
);
|
||||||
assetsFromExcludedAlbums.addAll(assets);
|
assetsFromExcludedAlbums.addAll(assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +328,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
// Find asset that were backup from selected albums
|
// Find asset that were backup from selected albums
|
||||||
Set<String> selectedAlbumsBackupAssets =
|
Set<String> selectedAlbumsBackupAssets =
|
||||||
Set.from(allUniqueAssets.map((e) => e.id));
|
Set.from(allUniqueAssets.map((e) => e.id));
|
||||||
|
|
||||||
selectedAlbumsBackupAssets
|
selectedAlbumsBackupAssets
|
||||||
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
||||||
|
|
||||||
|
// Remove duplicated asset from all unique assets
|
||||||
|
allUniqueAssets.removeWhere(
|
||||||
|
(asset) => duplicatedAssetIds.contains(asset.id),
|
||||||
|
);
|
||||||
|
|
||||||
if (allUniqueAssets.isEmpty) {
|
if (allUniqueAssets.isEmpty) {
|
||||||
debugPrint("No Asset On Device");
|
debugPrint("No Asset On Device");
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -353,11 +369,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||||
state = state.copyWith(backgroundBackup: isEnabled);
|
state = state.copyWith(backgroundBackup: isEnabled);
|
||||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||||
await Future.wait([
|
await _getBackupAlbumsInfo();
|
||||||
_getBackupAlbumsInfo(),
|
await _updateServerInfo();
|
||||||
_updateServerInfo(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await _updateBackupAssetCount();
|
await _updateBackupAssetCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,14 +463,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
void _onAssetUploaded(
|
||||||
state = state.copyWith(
|
String deviceAssetId,
|
||||||
selectedAlbumsBackupAssetsIds: {
|
String deviceId,
|
||||||
...state.selectedAlbumsBackupAssetsIds,
|
bool isDuplicated,
|
||||||
deviceAssetId
|
) {
|
||||||
},
|
if (isDuplicated) {
|
||||||
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
|
state = state.copyWith(
|
||||||
);
|
allUniqueAssets: state.allUniqueAssets
|
||||||
|
.where((asset) => asset.id != deviceAssetId)
|
||||||
|
.toSet(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
selectedAlbumsBackupAssetsIds: {
|
||||||
|
...state.selectedAlbumsBackupAssetsIds,
|
||||||
|
deviceAssetId
|
||||||
|
},
|
||||||
|
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (state.allUniqueAssets.length -
|
if (state.allUniqueAssets.length -
|
||||||
state.selectedAlbumsBackupAssetsIds.length ==
|
state.selectedAlbumsBackupAssetsIds.length ==
|
||||||
@@ -559,6 +584,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
albums.lastExcludedBackupTime,
|
albums.lastExcludedBackupTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
|
||||||
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
|
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
backupProgress: previous,
|
backupProgress: previous,
|
||||||
@@ -603,6 +629,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
|
||||||
|
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
|
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
|
||||||
await Hive.box(backgroundBackupInfoBox).close();
|
await Hive.box(backgroundBackupInfoBox).close();
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import 'package:http_parser/http_parser.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:cancellation_token_http/http.dart' as http;
|
import 'package:cancellation_token_http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../models/hive_duplicated_assets.model.dart';
|
||||||
|
|
||||||
final backupServiceProvider = Provider(
|
final backupServiceProvider = Provider(
|
||||||
(ref) => BackupService(
|
(ref) => BackupService(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
@@ -41,6 +43,29 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _saveDuplicatedAssetIdToLocalStorage(List<String> deviceAssetIds) {
|
||||||
|
HiveDuplicatedAssets duplicatedAssets =
|
||||||
|
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||||
|
.get(duplicatedAssetsKey) ??
|
||||||
|
HiveDuplicatedAssets(duplicatedAssetIds: []);
|
||||||
|
|
||||||
|
duplicatedAssets.duplicatedAssetIds =
|
||||||
|
{...duplicatedAssets.duplicatedAssetIds, ...deviceAssetIds}.toList();
|
||||||
|
|
||||||
|
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||||
|
.put(duplicatedAssetsKey, duplicatedAssets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get duplicated asset id from Hive storage
|
||||||
|
Set<String> getDuplicatedAssetIds() {
|
||||||
|
HiveDuplicatedAssets duplicatedAssets =
|
||||||
|
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||||
|
.get(duplicatedAssetsKey) ??
|
||||||
|
HiveDuplicatedAssets(duplicatedAssetIds: []);
|
||||||
|
|
||||||
|
return duplicatedAssets.duplicatedAssetIds.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns all assets newer than the last successful backup per album
|
/// Returns all assets newer than the last successful backup per album
|
||||||
Future<List<AssetEntity>> buildUploadCandidates(
|
Future<List<AssetEntity>> buildUploadCandidates(
|
||||||
HiveBackupAlbums backupAlbums,
|
HiveBackupAlbums backupAlbums,
|
||||||
@@ -127,7 +152,9 @@ class BackupService {
|
|||||||
for (int i = 0; i < albums.length; i++) {
|
for (int i = 0; i < albums.length; i++) {
|
||||||
final AssetPathEntity? a = albums[i];
|
final AssetPathEntity? a = albums[i];
|
||||||
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
|
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
|
||||||
result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount));
|
result.addAll(
|
||||||
|
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
||||||
|
);
|
||||||
lastBackup[i] = now;
|
lastBackup[i] = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,34 +165,47 @@ class BackupService {
|
|||||||
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||||
List<AssetEntity> candidates,
|
List<AssetEntity> candidates,
|
||||||
) async {
|
) async {
|
||||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
if (candidates.isEmpty) {
|
||||||
if (candidates.length < 10) {
|
return candidates;
|
||||||
final List<CheckDuplicateAssetResponseDto?> duplicateResponse =
|
}
|
||||||
await Future.wait(
|
final Set<String> duplicatedAssetIds = getDuplicatedAssetIds();
|
||||||
candidates.map(
|
candidates = duplicatedAssetIds.isEmpty
|
||||||
(e) => _apiService.assetApi.checkDuplicateAsset(
|
? candidates
|
||||||
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId),
|
: candidates
|
||||||
),
|
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
|
||||||
|
.toList();
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
final Set<String> existing = {};
|
||||||
|
try {
|
||||||
|
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||||
|
final CheckExistingAssetsResponseDto? duplicates =
|
||||||
|
await _apiService.assetApi.checkExistingAssets(
|
||||||
|
CheckExistingAssetsDto(
|
||||||
|
deviceAssetIds: candidates.map((e) => e.id).toList(),
|
||||||
|
deviceId: deviceId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return candidates
|
if (duplicates != null) {
|
||||||
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false)
|
existing.addAll(duplicates.existingIds);
|
||||||
.toList();
|
}
|
||||||
} else {
|
} on ApiException {
|
||||||
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
// workaround for older server versions or when checking for too many assets at once
|
||||||
|
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||||
if (allAssetsInDatabase == null) {
|
if (allAssetsInDatabase != null) {
|
||||||
return candidates;
|
existing.addAll(allAssetsInDatabase);
|
||||||
}
|
}
|
||||||
final Set<String> inDb = allAssetsInDatabase.toSet();
|
|
||||||
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
|
|
||||||
}
|
}
|
||||||
|
return existing.isEmpty
|
||||||
|
? candidates
|
||||||
|
: candidates.whereNot((e) => existing.contains(e.id)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> backupAsset(
|
Future<bool> backupAsset(
|
||||||
Iterable<AssetEntity> assetList,
|
Iterable<AssetEntity> assetList,
|
||||||
http.CancellationToken cancelToken,
|
http.CancellationToken cancelToken,
|
||||||
Function(String, String) singleAssetDoneCb,
|
Function(String, String, bool) uploadSuccessCb,
|
||||||
Function(int, int) uploadProgressCb,
|
Function(int, int) uploadProgressCb,
|
||||||
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
||||||
Function(ErrorUploadAsset) errorCb,
|
Function(ErrorUploadAsset) errorCb,
|
||||||
@@ -174,6 +214,7 @@ class BackupService {
|
|||||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||||
File? file;
|
File? file;
|
||||||
bool anyErrors = false;
|
bool anyErrors = false;
|
||||||
|
final List<String> duplicatedAssetIds = [];
|
||||||
|
|
||||||
for (var entity in assetList) {
|
for (var entity in assetList) {
|
||||||
try {
|
try {
|
||||||
@@ -233,8 +274,13 @@ class BackupService {
|
|||||||
|
|
||||||
var response = await req.send(cancellationToken: cancelToken);
|
var response = await req.send(cancellationToken: cancelToken);
|
||||||
|
|
||||||
if (response.statusCode == 201) {
|
if (response.statusCode == 200) {
|
||||||
singleAssetDoneCb(entity.id, deviceId);
|
// asset is a duplicate (already exists on the server)
|
||||||
|
duplicatedAssetIds.add(entity.id);
|
||||||
|
uploadSuccessCb(entity.id, deviceId, true);
|
||||||
|
} else if (response.statusCode == 201) {
|
||||||
|
// stored a new asset on the server
|
||||||
|
uploadSuccessCb(entity.id, deviceId, false);
|
||||||
} else {
|
} else {
|
||||||
var data = await response.stream.bytesToString();
|
var data = await response.stream.bytesToString();
|
||||||
var error = jsonDecode(data);
|
var error = jsonDecode(data);
|
||||||
@@ -258,7 +304,8 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
} on http.CancelledException {
|
} on http.CancelledException {
|
||||||
debugPrint("Backup was cancelled by the user");
|
debugPrint("Backup was cancelled by the user");
|
||||||
return false;
|
anyErrors = true;
|
||||||
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||||
anyErrors = true;
|
anyErrors = true;
|
||||||
@@ -269,6 +316,9 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (duplicatedAssetIds.isNotEmpty) {
|
||||||
|
_saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds);
|
||||||
|
}
|
||||||
return !anyErrors;
|
return !anyErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
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';
|
||||||
@@ -205,15 +203,23 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 2.0),
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
child: Text(
|
child: FutureBuilder(
|
||||||
albumInfo.assetCount.toString() +
|
builder: ((context, snapshot) {
|
||||||
(albumInfo.isAll
|
if (snapshot.hasData) {
|
||||||
? " (${'backup_all'.tr()})"
|
return Text(
|
||||||
: ""),
|
snapshot.data.toString() +
|
||||||
style: TextStyle(
|
(albumInfo.isAll
|
||||||
fontSize: 12,
|
? " (${'backup_all'.tr()})"
|
||||||
color: Colors.grey[600],
|
: ""),
|
||||||
),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text("0");
|
||||||
|
}),
|
||||||
|
future: albumInfo.assetCount,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ class AlbumPreviewPage extends HookConsumerWidget {
|
|||||||
final assets = useState<List<AssetEntity>>([]);
|
final assets = useState<List<AssetEntity>>([]);
|
||||||
|
|
||||||
_getAssetsInAlbum() async {
|
_getAssetsInAlbum() async {
|
||||||
assets.value =
|
assets.value = await album.getAssetListRange(
|
||||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
start: 0, end: await album.assetCountAsync);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -34,7 +34,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
|
|||||||
title: Column(
|
title: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"${album.name} (${album.assetCount})",
|
"${album.name} (${album.assetCountAsync})",
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showBatteryOptimizationInfoToUser() {
|
void _showBatteryOptimizationInfoToUser() {
|
||||||
final buttonTextColor = Theme.of(context).primaryColor;
|
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -173,13 +172,14 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
OutlinedButton(
|
ElevatedButton(
|
||||||
onPressed: () => launchUrl(
|
onPressed: () => launchUrl(
|
||||||
Uri.parse('https://dontkillmyapp.com'),
|
Uri.parse('https://dontkillmyapp.com'),
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
"backup_controller_page_background_battery_info_link",
|
"backup_controller_page_background_battery_info_link",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -220,7 +220,12 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (!isBackgroundEnabled)
|
if (!isBackgroundEnabled)
|
||||||
const Text("backup_controller_page_background_description").tr(),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child:
|
||||||
|
const Text("backup_controller_page_background_description")
|
||||||
|
.tr(),
|
||||||
|
),
|
||||||
if (isBackgroundEnabled)
|
if (isBackgroundEnabled)
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title:
|
title:
|
||||||
@@ -414,7 +419,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
ActionChip(
|
ActionChip(
|
||||||
avatar: Icon(
|
avatar: Icon(
|
||||||
Icons.info,
|
Icons.info,
|
||||||
size: 24,
|
|
||||||
color: Colors.red[400],
|
color: Colors.red[400],
|
||||||
),
|
),
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
@@ -508,7 +512,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
DateTime.parse(
|
DateTime.parse(
|
||||||
backupState.currentUploadAsset.createdAt
|
backupState.currentUploadAsset.createdAt
|
||||||
.toString(),
|
.toString(),
|
||||||
),
|
).toLocal(),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
|||||||
DateFormat.yMMMMd('en_US').format(
|
DateFormat.yMMMMd('en_US').format(
|
||||||
DateTime.parse(
|
DateTime.parse(
|
||||||
errorAsset.createdAt.toString(),
|
errorAsset.createdAt.toString(),
|
||||||
),
|
).toLocal(),
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
|
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
class HomePageState {
|
|
||||||
final bool isMultiSelectEnable;
|
|
||||||
final Set<AssetResponseDto> selectedItems;
|
|
||||||
final Set<String> selectedDateGroup;
|
|
||||||
HomePageState({
|
|
||||||
required this.isMultiSelectEnable,
|
|
||||||
required this.selectedItems,
|
|
||||||
required this.selectedDateGroup,
|
|
||||||
});
|
|
||||||
|
|
||||||
HomePageState copyWith({
|
|
||||||
bool? isMultiSelectEnable,
|
|
||||||
Set<AssetResponseDto>? selectedItems,
|
|
||||||
Set<String>? selectedDateGroup,
|
|
||||||
}) {
|
|
||||||
return HomePageState(
|
|
||||||
isMultiSelectEnable: isMultiSelectEnable ?? this.isMultiSelectEnable,
|
|
||||||
selectedItems: selectedItems ?? this.selectedItems,
|
|
||||||
selectedDateGroup: selectedDateGroup ?? this.selectedDateGroup,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() =>
|
|
||||||
'HomePageState(isMultiSelectEnable: $isMultiSelectEnable, selectedItems: $selectedItems, selectedDateGroup: $selectedDateGroup)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
final setEquals = const DeepCollectionEquality().equals;
|
|
||||||
|
|
||||||
return other is HomePageState &&
|
|
||||||
other.isMultiSelectEnable == isMultiSelectEnable &&
|
|
||||||
setEquals(other.selectedItems, selectedItems) &&
|
|
||||||
setEquals(other.selectedDateGroup, selectedDateGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
isMultiSelectEnable.hashCode ^
|
|
||||||
selectedItems.hashCode ^
|
|
||||||
selectedDateGroup.hashCode;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
|
||||||
|
final renderListProvider = StateProvider((ref) {
|
||||||
|
var assetGroups = ref.watch(assetGroupByDateTimeProvider);
|
||||||
|
|
||||||
|
var settings = ref.watch(appSettingsServiceProvider);
|
||||||
|
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
|
||||||
|
|
||||||
|
return assetGroupsToRenderList(assetGroups, assetsPerRow);
|
||||||
|
});
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
|
|
||||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
|
||||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
class HomePageStateNotifier extends StateNotifier<HomePageState> {
|
|
||||||
|
|
||||||
final ShareService _shareService;
|
|
||||||
|
|
||||||
HomePageStateNotifier(this._shareService)
|
|
||||||
: super(
|
|
||||||
HomePageState(
|
|
||||||
isMultiSelectEnable: false,
|
|
||||||
selectedItems: {},
|
|
||||||
selectedDateGroup: {},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
void addSelectedDateGroup(String dateGroupTitle) {
|
|
||||||
state = state.copyWith(
|
|
||||||
selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeSelectedDateGroup(String dateGroupTitle) {
|
|
||||||
var currentDateGroup = state.selectedDateGroup;
|
|
||||||
|
|
||||||
currentDateGroup.removeWhere((e) => e == dateGroupTitle);
|
|
||||||
|
|
||||||
state = state.copyWith(selectedDateGroup: currentDateGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
void enableMultiSelect(Set<AssetResponseDto> selectedItems) {
|
|
||||||
state =
|
|
||||||
state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
void disableMultiSelect() {
|
|
||||||
state = state.copyWith(
|
|
||||||
isMultiSelectEnable: false,
|
|
||||||
selectedItems: {},
|
|
||||||
selectedDateGroup: {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void addSingleSelectedItem(AssetResponseDto asset) {
|
|
||||||
state = state.copyWith(selectedItems: {...state.selectedItems, asset});
|
|
||||||
}
|
|
||||||
|
|
||||||
void addMultipleSelectedItems(List<AssetResponseDto> assets) {
|
|
||||||
state = state.copyWith(selectedItems: {...state.selectedItems, ...assets});
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeSingleSelectedItem(AssetResponseDto asset) {
|
|
||||||
Set<AssetResponseDto> currentList = state.selectedItems;
|
|
||||||
|
|
||||||
currentList.removeWhere((e) => e.id == asset.id);
|
|
||||||
|
|
||||||
state = state.copyWith(selectedItems: currentList);
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeMultipleSelectedItem(List<AssetResponseDto> assets) {
|
|
||||||
Set<AssetResponseDto> currentList = state.selectedItems;
|
|
||||||
|
|
||||||
for (AssetResponseDto asset in assets) {
|
|
||||||
currentList.removeWhere((e) => e.id == asset.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(selectedItems: currentList);
|
|
||||||
}
|
|
||||||
|
|
||||||
void shareAssets(List<AssetResponseDto> assets, BuildContext context) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext buildContext) {
|
|
||||||
_shareService
|
|
||||||
.shareAssets(assets)
|
|
||||||
.then((_) => Navigator.of(buildContext).pop());
|
|
||||||
return const ShareDialog();
|
|
||||||
},
|
|
||||||
barrierDismissible: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final homePageStateProvider =
|
|
||||||
StateNotifierProvider<HomePageStateNotifier, HomePageState>(
|
|
||||||
((ref) => HomePageStateNotifier(ref.watch(shareServiceProvider))),
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
final multiselectProvider = StateProvider((ref) {
|
||||||
|
return false;
|
||||||
|
});
|
||||||
37
mobile/lib/modules/home/services/asset_cache.service.dart
Normal file
37
mobile/lib/modules/home/services/asset_cache.service.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/json_cache.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
|
||||||
|
AssetCacheService() : super("asset_cache");
|
||||||
|
|
||||||
|
@override
|
||||||
|
void put(List<AssetResponseDto> data) {
|
||||||
|
putRawData(data.map((e) => e.toJson()).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AssetResponseDto>> get() async {
|
||||||
|
try {
|
||||||
|
final mapList = await readRawData() as List<dynamic>;
|
||||||
|
|
||||||
|
final responseData = mapList
|
||||||
|
.map((e) => AssetResponseDto.fromJson(e))
|
||||||
|
.whereNotNull()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return responseData;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final assetCacheServiceProvider = Provider(
|
||||||
|
(ref) => AssetCacheService(),
|
||||||
|
);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
enum RenderAssetGridElementType {
|
||||||
|
assetRow,
|
||||||
|
dayTitle,
|
||||||
|
monthTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenderAssetGridRow {
|
||||||
|
final List<AssetResponseDto> assets;
|
||||||
|
|
||||||
|
RenderAssetGridRow(this.assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenderAssetGridElement {
|
||||||
|
final RenderAssetGridElementType type;
|
||||||
|
final RenderAssetGridRow? assetRow;
|
||||||
|
final String? title;
|
||||||
|
final DateTime date;
|
||||||
|
final List<AssetResponseDto>? relatedAssetList;
|
||||||
|
|
||||||
|
RenderAssetGridElement(
|
||||||
|
this.type, {
|
||||||
|
this.assetRow,
|
||||||
|
this.title,
|
||||||
|
required this.date,
|
||||||
|
this.relatedAssetList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RenderAssetGridElement> assetsToRenderList(
|
||||||
|
List<AssetResponseDto> assets, int assetsPerRow) {
|
||||||
|
List<RenderAssetGridElement> elements = [];
|
||||||
|
|
||||||
|
int cursor = 0;
|
||||||
|
while (cursor < assets.length) {
|
||||||
|
int rowElements = min(assets.length - cursor, assetsPerRow);
|
||||||
|
final date = DateTime.parse(assets[cursor].createdAt);
|
||||||
|
|
||||||
|
final rowElement = RenderAssetGridElement(
|
||||||
|
RenderAssetGridElementType.assetRow,
|
||||||
|
date: date,
|
||||||
|
assetRow: RenderAssetGridRow(
|
||||||
|
assets.sublist(cursor, cursor + rowElements),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.add(rowElement);
|
||||||
|
cursor += rowElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RenderAssetGridElement> assetGroupsToRenderList(
|
||||||
|
Map<String, List<AssetResponseDto>> assetGroups, int assetsPerRow) {
|
||||||
|
List<RenderAssetGridElement> elements = [];
|
||||||
|
DateTime? lastDate;
|
||||||
|
|
||||||
|
assetGroups.forEach((groupName, assets) {
|
||||||
|
final date = DateTime.parse(groupName);
|
||||||
|
|
||||||
|
if (lastDate == null || lastDate!.month != date.month) {
|
||||||
|
elements.add(
|
||||||
|
RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
|
||||||
|
title: groupName, date: date),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add group title
|
||||||
|
elements.add(
|
||||||
|
RenderAssetGridElement(
|
||||||
|
RenderAssetGridElementType.dayTitle,
|
||||||
|
title: groupName,
|
||||||
|
date: date,
|
||||||
|
relatedAssetList: assets,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add rows
|
||||||
|
int cursor = 0;
|
||||||
|
while (cursor < assets.length) {
|
||||||
|
int rowElements = min(assets.length - cursor, assetsPerRow);
|
||||||
|
|
||||||
|
final rowElement = RenderAssetGridElement(
|
||||||
|
RenderAssetGridElementType.assetRow,
|
||||||
|
date: date,
|
||||||
|
assetRow: RenderAssetGridRow(
|
||||||
|
assets.sublist(cursor, cursor + rowElements),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.add(rowElement);
|
||||||
|
cursor += rowElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDate = date;
|
||||||
|
});
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
72
mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart
Normal file
72
mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
class DailyTitleText extends ConsumerWidget {
|
||||||
|
const DailyTitleText({
|
||||||
|
Key? key,
|
||||||
|
required this.isoDate,
|
||||||
|
required this.multiselectEnabled,
|
||||||
|
required this.onSelect,
|
||||||
|
required this.onDeselect,
|
||||||
|
required this.selected,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final String isoDate;
|
||||||
|
final bool multiselectEnabled;
|
||||||
|
final Function onSelect;
|
||||||
|
final Function onDeselect;
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
var currentYear = DateTime.now().year;
|
||||||
|
var groupYear = DateTime.parse(isoDate).year;
|
||||||
|
var formatDateTemplate = currentYear == groupYear
|
||||||
|
? "daily_title_text_date".tr()
|
||||||
|
: "daily_title_text_date_year".tr();
|
||||||
|
var dateText =
|
||||||
|
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
||||||
|
|
||||||
|
void handleTitleIconClick() {
|
||||||
|
if (selected) {
|
||||||
|
onDeselect();
|
||||||
|
} else {
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 29.0,
|
||||||
|
bottom: 29.0,
|
||||||
|
left: 12.0,
|
||||||
|
right: 12.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
dateText,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: handleTitleIconClick,
|
||||||
|
child: multiselectEnabled && selected
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
class DisableMultiSelectButton extends ConsumerWidget {
|
class DisableMultiSelectButton extends ConsumerWidget {
|
||||||
const DisableMultiSelectButton({
|
const DisableMultiSelectButton({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
required this.selectedItemCount,
|
required this.selectedItemCount,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final Function onPressed;
|
final Function onPressed;
|
||||||
final int selectedItemCount;
|
final int selectedItemCount;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Positioned(
|
return Padding(
|
||||||
top: 10,
|
padding: const EdgeInsets.only(left: 16.0, top: 15),
|
||||||
left: 0,
|
child: Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
padding: const EdgeInsets.only(left: 16.0, top: 46),
|
child: ElevatedButton.icon(
|
||||||
child: Padding(
|
onPressed: () {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
onPressed();
|
||||||
child: ElevatedButton.icon(
|
},
|
||||||
onPressed: () {
|
icon: const Icon(Icons.close_rounded),
|
||||||
onPressed();
|
label: Text(
|
||||||
},
|
'$selectedItemCount',
|
||||||
icon: const Icon(Icons.close_rounded),
|
style: const TextStyle(
|
||||||
label: Text(
|
fontWeight: FontWeight.w600,
|
||||||
'$selectedItemCount',
|
fontSize: 18,
|
||||||
style: const TextStyle(
|
),
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
fontSize: 18,
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
),
|
}
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
|
||||||
|
/// Build the Scroll Thumb and label using the current configuration
|
||||||
|
typedef ScrollThumbBuilder = Widget Function(
|
||||||
|
Color backgroundColor,
|
||||||
|
Animation<double> thumbAnimation,
|
||||||
|
Animation<double> labelAnimation,
|
||||||
|
double height, {
|
||||||
|
Text? labelText,
|
||||||
|
BoxConstraints? labelConstraints,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Build a Text widget using the current scroll offset
|
||||||
|
typedef LabelTextBuilder = Text Function(int item);
|
||||||
|
|
||||||
|
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||||
|
/// for quick navigation of the BoxScrollView.
|
||||||
|
class DraggableScrollbar extends StatefulWidget {
|
||||||
|
/// The view that will be scrolled with the scroll thumb
|
||||||
|
final ScrollablePositionedList child;
|
||||||
|
|
||||||
|
final ItemPositionsListener itemPositionsListener;
|
||||||
|
|
||||||
|
/// A function that builds a thumb using the current configuration
|
||||||
|
final ScrollThumbBuilder scrollThumbBuilder;
|
||||||
|
|
||||||
|
/// The height of the scroll thumb
|
||||||
|
final double heightScrollThumb;
|
||||||
|
|
||||||
|
/// The background color of the label and thumb
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// The amount of padding that should surround the thumb
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
|
||||||
|
/// Determines how quickly the scrollbar will animate in and out
|
||||||
|
final Duration scrollbarAnimationDuration;
|
||||||
|
|
||||||
|
/// How long should the thumb be visible before fading out
|
||||||
|
final Duration scrollbarTimeToFade;
|
||||||
|
|
||||||
|
/// Build a Text widget from the current offset in the BoxScrollView
|
||||||
|
final LabelTextBuilder? labelTextBuilder;
|
||||||
|
|
||||||
|
/// Determines box constraints for Container displaying label
|
||||||
|
final BoxConstraints? labelConstraints;
|
||||||
|
|
||||||
|
/// The ScrollController for the BoxScrollView
|
||||||
|
final ItemScrollController controller;
|
||||||
|
|
||||||
|
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
|
||||||
|
final bool alwaysVisibleScrollThumb;
|
||||||
|
|
||||||
|
final Function(bool scrolling) scrollStateListener;
|
||||||
|
|
||||||
|
DraggableScrollbar.semicircle({
|
||||||
|
Key? key,
|
||||||
|
Key? scrollThumbKey,
|
||||||
|
this.alwaysVisibleScrollThumb = false,
|
||||||
|
required this.child,
|
||||||
|
required this.controller,
|
||||||
|
required this.itemPositionsListener,
|
||||||
|
required this.scrollStateListener,
|
||||||
|
this.heightScrollThumb = 48.0,
|
||||||
|
this.backgroundColor = Colors.white,
|
||||||
|
this.padding,
|
||||||
|
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||||
|
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||||
|
this.labelTextBuilder,
|
||||||
|
this.labelConstraints,
|
||||||
|
}) : assert(child.scrollDirection == Axis.vertical),
|
||||||
|
scrollThumbBuilder = _thumbSemicircleBuilder(
|
||||||
|
heightScrollThumb * 0.6,
|
||||||
|
scrollThumbKey,
|
||||||
|
alwaysVisibleScrollThumb,
|
||||||
|
),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
DraggableScrollbarState createState() => DraggableScrollbarState();
|
||||||
|
|
||||||
|
static buildScrollThumbAndLabel({
|
||||||
|
required Widget scrollThumb,
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Animation<double>? thumbAnimation,
|
||||||
|
required Animation<double>? labelAnimation,
|
||||||
|
required Text? labelText,
|
||||||
|
required BoxConstraints? labelConstraints,
|
||||||
|
required bool alwaysVisibleScrollThumb,
|
||||||
|
}) {
|
||||||
|
var scrollThumbAndLabel = labelText == null
|
||||||
|
? scrollThumb
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ScrollLabel(
|
||||||
|
animation: labelAnimation,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
constraints: labelConstraints,
|
||||||
|
child: labelText,
|
||||||
|
),
|
||||||
|
scrollThumb,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alwaysVisibleScrollThumb) {
|
||||||
|
return scrollThumbAndLabel;
|
||||||
|
}
|
||||||
|
return SlideFadeTransition(
|
||||||
|
animation: thumbAnimation!,
|
||||||
|
child: scrollThumbAndLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScrollThumbBuilder _thumbSemicircleBuilder(
|
||||||
|
double width,
|
||||||
|
Key? scrollThumbKey,
|
||||||
|
bool alwaysVisibleScrollThumb,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
Color backgroundColor,
|
||||||
|
Animation<double> thumbAnimation,
|
||||||
|
Animation<double> labelAnimation,
|
||||||
|
double height, {
|
||||||
|
Text? labelText,
|
||||||
|
BoxConstraints? labelConstraints,
|
||||||
|
}) {
|
||||||
|
final scrollThumb = CustomPaint(
|
||||||
|
key: scrollThumbKey,
|
||||||
|
foregroundPainter: ArrowCustomPainter(Colors.white),
|
||||||
|
child: Material(
|
||||||
|
elevation: 4.0,
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(height),
|
||||||
|
bottomLeft: Radius.circular(height),
|
||||||
|
topRight: const Radius.circular(4.0),
|
||||||
|
bottomRight: const Radius.circular(4.0),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints.tight(Size(width, height)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildScrollThumbAndLabel(
|
||||||
|
scrollThumb: scrollThumb,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
thumbAnimation: thumbAnimation,
|
||||||
|
labelAnimation: labelAnimation,
|
||||||
|
labelText: labelText,
|
||||||
|
labelConstraints: labelConstraints,
|
||||||
|
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollLabel extends StatelessWidget {
|
||||||
|
final Animation<double>? animation;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Text child;
|
||||||
|
|
||||||
|
final BoxConstraints? constraints;
|
||||||
|
static const BoxConstraints _defaultConstraints =
|
||||||
|
BoxConstraints.tightFor(width: 72.0, height: 28.0);
|
||||||
|
|
||||||
|
const ScrollLabel({
|
||||||
|
Key? key,
|
||||||
|
required this.child,
|
||||||
|
required this.animation,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.constraints = _defaultConstraints,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation!,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(right: 12.0),
|
||||||
|
child: Material(
|
||||||
|
elevation: 4.0,
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||||
|
child: Container(
|
||||||
|
constraints: constraints ?? _defaultConstraints,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late double _barOffset;
|
||||||
|
late bool _isDragInProcess;
|
||||||
|
late int _currentItem;
|
||||||
|
|
||||||
|
late AnimationController _thumbAnimationController;
|
||||||
|
late Animation<double> _thumbAnimation;
|
||||||
|
late AnimationController _labelAnimationController;
|
||||||
|
late Animation<double> _labelAnimation;
|
||||||
|
Timer? _fadeoutTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_barOffset = 0.0;
|
||||||
|
_isDragInProcess = false;
|
||||||
|
_currentItem = 0;
|
||||||
|
|
||||||
|
_thumbAnimationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: widget.scrollbarAnimationDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
_thumbAnimation = CurvedAnimation(
|
||||||
|
parent: _thumbAnimationController,
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
|
||||||
|
_labelAnimationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: widget.scrollbarAnimationDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
_labelAnimation = CurvedAnimation(
|
||||||
|
parent: _labelAnimationController,
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_thumbAnimationController.dispose();
|
||||||
|
_labelAnimationController.dispose();
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
double get barMaxScrollExtent =>
|
||||||
|
(context.size?.height ?? 0) - widget.heightScrollThumb;
|
||||||
|
|
||||||
|
double get barMinScrollExtent => 0;
|
||||||
|
|
||||||
|
int get maxItemCount => widget.child.itemCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Text? labelText;
|
||||||
|
if (widget.labelTextBuilder != null && _isDragInProcess) {
|
||||||
|
labelText = widget.labelTextBuilder!(_currentItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
//print("LayoutBuilder constraints=$constraints");
|
||||||
|
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (ScrollNotification notification) {
|
||||||
|
changePosition(notification);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
RepaintBoundary(
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
RepaintBoundary(
|
||||||
|
child: GestureDetector(
|
||||||
|
onVerticalDragStart: _onVerticalDragStart,
|
||||||
|
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||||
|
onVerticalDragEnd: _onVerticalDragEnd,
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
margin: EdgeInsets.only(top: _barOffset),
|
||||||
|
padding: widget.padding,
|
||||||
|
child: widget.scrollThumbBuilder(
|
||||||
|
widget.backgroundColor,
|
||||||
|
_thumbAnimation,
|
||||||
|
_labelAnimation,
|
||||||
|
widget.heightScrollThumb,
|
||||||
|
labelText: labelText,
|
||||||
|
labelConstraints: widget.labelConstraints,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// scroll bar has received notification that it's view was scrolled
|
||||||
|
// so it should also changes his position
|
||||||
|
// but only if it isn't dragged
|
||||||
|
changePosition(ScrollNotification notification) {
|
||||||
|
if (_isDragInProcess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
int firstItemIndex =
|
||||||
|
widget.itemPositionsListener.itemPositions.value.first.index;
|
||||||
|
|
||||||
|
if (notification is ScrollUpdateNotification) {
|
||||||
|
_barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
|
||||||
|
|
||||||
|
if (_barOffset < barMinScrollExtent) {
|
||||||
|
_barOffset = barMinScrollExtent;
|
||||||
|
}
|
||||||
|
if (_barOffset > barMaxScrollExtent) {
|
||||||
|
_barOffset = barMaxScrollExtent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification is ScrollUpdateNotification ||
|
||||||
|
notification is OverscrollNotification) {
|
||||||
|
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||||
|
_thumbAnimationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemPos < maxItemCount) {
|
||||||
|
_currentItem = itemPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||||
|
_thumbAnimationController.reverse();
|
||||||
|
_labelAnimationController.reverse();
|
||||||
|
_fadeoutTimer = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVerticalDragStart(DragStartDetails details) {
|
||||||
|
setState(() {
|
||||||
|
_isDragInProcess = true;
|
||||||
|
_labelAnimationController.forward();
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.scrollStateListener(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get itemPos {
|
||||||
|
int numberOfItems = widget.child.itemCount;
|
||||||
|
return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _jumpToBarPos() {
|
||||||
|
if (itemPos > maxItemCount - 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentItem = itemPos;
|
||||||
|
|
||||||
|
widget.controller.jumpTo(
|
||||||
|
index: itemPos,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer? dragHaltTimer;
|
||||||
|
int lastTimerPos = 0;
|
||||||
|
|
||||||
|
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||||
|
setState(() {
|
||||||
|
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||||
|
_thumbAnimationController.forward();
|
||||||
|
}
|
||||||
|
if (_isDragInProcess) {
|
||||||
|
_barOffset += details.delta.dy;
|
||||||
|
|
||||||
|
if (_barOffset < barMinScrollExtent) {
|
||||||
|
_barOffset = barMinScrollExtent;
|
||||||
|
}
|
||||||
|
if (_barOffset > barMaxScrollExtent) {
|
||||||
|
_barOffset = barMaxScrollExtent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemPos != lastTimerPos) {
|
||||||
|
lastTimerPos = itemPos;
|
||||||
|
dragHaltTimer?.cancel();
|
||||||
|
widget.scrollStateListener(true);
|
||||||
|
|
||||||
|
dragHaltTimer = Timer(
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
() {
|
||||||
|
widget.scrollStateListener(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_jumpToBarPos();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVerticalDragEnd(DragEndDetails details) {
|
||||||
|
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||||
|
_thumbAnimationController.reverse();
|
||||||
|
_labelAnimationController.reverse();
|
||||||
|
_fadeoutTimer = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_jumpToBarPos();
|
||||||
|
_isDragInProcess = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.scrollStateListener(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws 2 triangles like arrow up and arrow down
|
||||||
|
class ArrowCustomPainter extends CustomPainter {
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
ArrowCustomPainter(this.color);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = color;
|
||||||
|
const width = 12.0;
|
||||||
|
const height = 8.0;
|
||||||
|
final baseX = size.width / 2;
|
||||||
|
final baseY = size.height / 2;
|
||||||
|
|
||||||
|
canvas.drawPath(
|
||||||
|
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
canvas.drawPath(
|
||||||
|
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||||
|
return Path()
|
||||||
|
..moveTo(o.dx, o.dy)
|
||||||
|
..lineTo(o.dx + width, o.dy)
|
||||||
|
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||||
|
..close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///This cut 2 lines in arrow shape
|
||||||
|
class ArrowClipper extends CustomClipper<Path> {
|
||||||
|
@override
|
||||||
|
Path getClip(Size size) {
|
||||||
|
Path path = Path();
|
||||||
|
path.lineTo(0.0, size.height);
|
||||||
|
path.lineTo(size.width, size.height);
|
||||||
|
path.lineTo(size.width, 0.0);
|
||||||
|
path.lineTo(0.0, 0.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
double arrowWidth = 8.0;
|
||||||
|
double startPointX = (size.width - arrowWidth) / 2;
|
||||||
|
double startPointY = size.height / 2 - arrowWidth / 2;
|
||||||
|
path.moveTo(startPointX, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
|
||||||
|
path.lineTo(
|
||||||
|
startPointX + arrowWidth / 2,
|
||||||
|
startPointY - arrowWidth / 2 + 1.0,
|
||||||
|
);
|
||||||
|
path.lineTo(startPointX, startPointY + 1.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
startPointY = size.height / 2 + arrowWidth / 2;
|
||||||
|
path.moveTo(startPointX + arrowWidth, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
|
||||||
|
path.lineTo(startPointX, startPointY);
|
||||||
|
path.lineTo(startPointX, startPointY - 1.0);
|
||||||
|
path.lineTo(
|
||||||
|
startPointX + arrowWidth / 2,
|
||||||
|
startPointY + arrowWidth / 2 - 1.0,
|
||||||
|
);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SlideFadeTransition extends StatelessWidget {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const SlideFadeTransition({
|
||||||
|
Key? key,
|
||||||
|
required this.animation,
|
||||||
|
required this.child,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) =>
|
||||||
|
animation.value == 0.0 ? const SizedBox() : child!,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(
|
||||||
|
begin: const Offset(0.3, 0.0),
|
||||||
|
end: const Offset(0.0, 0.0),
|
||||||
|
).animate(animation),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
274
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
Normal file
274
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
import 'asset_grid_data_structure.dart';
|
||||||
|
import 'daily_title_text.dart';
|
||||||
|
import 'disable_multi_select_button.dart';
|
||||||
|
import 'draggable_scrollbar_custom.dart';
|
||||||
|
|
||||||
|
typedef ImmichAssetGridSelectionListener = void Function(
|
||||||
|
bool,
|
||||||
|
Set<AssetResponseDto>,
|
||||||
|
);
|
||||||
|
|
||||||
|
class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||||
|
final ItemPositionsListener _itemPositionsListener =
|
||||||
|
ItemPositionsListener.create();
|
||||||
|
|
||||||
|
bool _scrolling = false;
|
||||||
|
final Set<String> _selectedAssets = HashSet();
|
||||||
|
|
||||||
|
List<AssetResponseDto> get _assets {
|
||||||
|
return widget.renderList
|
||||||
|
.map((e) {
|
||||||
|
if (e.type == RenderAssetGridElementType.assetRow) {
|
||||||
|
return e.assetRow!.assets;
|
||||||
|
} else {
|
||||||
|
return List<AssetResponseDto>.empty();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flattened
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<AssetResponseDto> _getSelectedAssets() {
|
||||||
|
return _selectedAssets
|
||||||
|
.map((e) => _assets.firstWhereOrNull((a) => a.id == e))
|
||||||
|
.whereNotNull()
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _callSelectionListener(bool selectionActive) {
|
||||||
|
widget.listener?.call(selectionActive, _getSelectedAssets());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAssets(List<AssetResponseDto> assets) {
|
||||||
|
setState(() {
|
||||||
|
for (var e in assets) {
|
||||||
|
_selectedAssets.add(e.id);
|
||||||
|
}
|
||||||
|
_callSelectionListener(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deselectAssets(List<AssetResponseDto> assets) {
|
||||||
|
setState(() {
|
||||||
|
for (var e in assets) {
|
||||||
|
_selectedAssets.remove(e.id);
|
||||||
|
}
|
||||||
|
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deselectAll() {
|
||||||
|
setState(() {
|
||||||
|
_selectedAssets.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
_callSelectionListener(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _allAssetsSelected(List<AssetResponseDto> assets) {
|
||||||
|
return widget.selectionActive &&
|
||||||
|
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getItemSize(BuildContext context) {
|
||||||
|
return MediaQuery.of(context).size.width / widget.assetsPerRow -
|
||||||
|
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThumbnailOrPlaceholder(
|
||||||
|
AssetResponseDto asset,
|
||||||
|
bool placeholder,
|
||||||
|
) {
|
||||||
|
if (placeholder) {
|
||||||
|
return const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: Colors.grey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ThumbnailImage(
|
||||||
|
asset: asset,
|
||||||
|
assetList: _assets,
|
||||||
|
multiselectEnabled: widget.selectionActive,
|
||||||
|
isSelected: _selectedAssets.contains(asset.id),
|
||||||
|
onSelect: () => _selectAssets([asset]),
|
||||||
|
onDeselect: () => _deselectAssets([asset]),
|
||||||
|
useGrayBoxPlaceholder: true,
|
||||||
|
showStorageIndicator: widget.showStorageIndicator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAssetRow(
|
||||||
|
BuildContext context,
|
||||||
|
RenderAssetGridRow row,
|
||||||
|
bool scrolling,
|
||||||
|
) {
|
||||||
|
double size = _getItemSize(context);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
key: Key("asset-row-${row.assets.first.id}"),
|
||||||
|
children: row.assets.map((AssetResponseDto asset) {
|
||||||
|
bool last = asset == row.assets.last;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
key: Key("asset-${asset.id}"),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
margin: EdgeInsets.only(
|
||||||
|
top: widget.margin,
|
||||||
|
right: last ? 0.0 : widget.margin,
|
||||||
|
),
|
||||||
|
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitle(
|
||||||
|
BuildContext context,
|
||||||
|
String title,
|
||||||
|
List<AssetResponseDto> assets,
|
||||||
|
) {
|
||||||
|
return DailyTitleText(
|
||||||
|
isoDate: title,
|
||||||
|
multiselectEnabled: widget.selectionActive,
|
||||||
|
onSelect: () => _selectAssets(assets),
|
||||||
|
onDeselect: () => _deselectAssets(assets),
|
||||||
|
selected: _allAssetsSelected(assets),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMonthTitle(BuildContext context, String title) {
|
||||||
|
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||||
|
.format(DateTime.parse(title));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
key: Key("month-$title"),
|
||||||
|
padding: const EdgeInsets.only(left: 12.0, top: 32),
|
||||||
|
child: Text(
|
||||||
|
monthTitleText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).textTheme.headline1?.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _itemBuilder(BuildContext c, int position) {
|
||||||
|
final item = widget.renderList[position];
|
||||||
|
|
||||||
|
if (item.type == RenderAssetGridElementType.dayTitle) {
|
||||||
|
return _buildTitle(c, item.title!, item.relatedAssetList!);
|
||||||
|
} else if (item.type == RenderAssetGridElementType.monthTitle) {
|
||||||
|
return _buildMonthTitle(c, item.title!);
|
||||||
|
} else if (item.type == RenderAssetGridElementType.assetRow) {
|
||||||
|
return _buildAssetRow(c, item.assetRow!, _scrolling);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Text("Invalid widget type!");
|
||||||
|
}
|
||||||
|
|
||||||
|
Text _labelBuilder(int pos) {
|
||||||
|
final date = widget.renderList[pos].date;
|
||||||
|
return Text(
|
||||||
|
DateFormat.yMMMd().format(date),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMultiSelectIndicator() {
|
||||||
|
return DisableMultiSelectButton(
|
||||||
|
onPressed: () => _deselectAll(),
|
||||||
|
selectedItemCount: _selectedAssets.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAssetGrid() {
|
||||||
|
final useDragScrolling = _assets.length >= 20;
|
||||||
|
|
||||||
|
void dragScrolling(bool active) {
|
||||||
|
setState(() {
|
||||||
|
_scrolling = active;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final listWidget = ScrollablePositionedList.builder(
|
||||||
|
itemBuilder: _itemBuilder,
|
||||||
|
itemPositionsListener: _itemPositionsListener,
|
||||||
|
itemScrollController: _itemScrollController,
|
||||||
|
itemCount: widget.renderList.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!useDragScrolling) {
|
||||||
|
return listWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DraggableScrollbar.semicircle(
|
||||||
|
scrollStateListener: dragScrolling,
|
||||||
|
itemPositionsListener: _itemPositionsListener,
|
||||||
|
controller: _itemScrollController,
|
||||||
|
backgroundColor: Theme.of(context).hintColor,
|
||||||
|
labelTextBuilder: _labelBuilder,
|
||||||
|
labelConstraints: const BoxConstraints(maxHeight: 28),
|
||||||
|
scrollbarAnimationDuration: const Duration(seconds: 1),
|
||||||
|
scrollbarTimeToFade: const Duration(seconds: 4),
|
||||||
|
child: listWidget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ImmichAssetGrid oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (!widget.selectionActive) {
|
||||||
|
setState(() {
|
||||||
|
_selectedAssets.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
_buildAssetGrid(),
|
||||||
|
if (widget.selectionActive) _buildMultiSelectIndicator(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImmichAssetGrid extends StatefulWidget {
|
||||||
|
final List<RenderAssetGridElement> renderList;
|
||||||
|
final int assetsPerRow;
|
||||||
|
final double margin;
|
||||||
|
final bool showStorageIndicator;
|
||||||
|
final ImmichAssetGridSelectionListener? listener;
|
||||||
|
final bool selectionActive;
|
||||||
|
|
||||||
|
const ImmichAssetGrid({
|
||||||
|
super.key,
|
||||||
|
required this.renderList,
|
||||||
|
required this.assetsPerRow,
|
||||||
|
required this.showStorageIndicator,
|
||||||
|
this.listener,
|
||||||
|
this.margin = 5.0,
|
||||||
|
this.selectionActive = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() {
|
||||||
|
return ImmichAssetGridState();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ class MonthlyTitleText extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||||
.format(DateTime.parse(isoDate));
|
.format(DateTime.parse(isoDate).toLocal());
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -1,150 +1,172 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
class ThumbnailImage extends HookConsumerWidget {
|
||||||
import 'package:openapi/api.dart';
|
final AssetResponseDto asset;
|
||||||
|
final List<AssetResponseDto> assetList;
|
||||||
class ThumbnailImage extends HookConsumerWidget {
|
final bool showStorageIndicator;
|
||||||
final AssetResponseDto asset;
|
final bool useGrayBoxPlaceholder;
|
||||||
final List<AssetResponseDto> assetList;
|
final bool isSelected;
|
||||||
final bool showStorageIndicator;
|
final bool multiselectEnabled;
|
||||||
final BaseCacheManager? cacheManager;
|
final Function? onSelect;
|
||||||
|
final Function? onDeselect;
|
||||||
const ThumbnailImage({
|
|
||||||
Key? key,
|
const ThumbnailImage({
|
||||||
required this.asset,
|
Key? key,
|
||||||
required this.assetList,
|
required this.asset,
|
||||||
this.cacheManager,
|
required this.assetList,
|
||||||
this.showStorageIndicator = true,
|
this.showStorageIndicator = true,
|
||||||
}) : super(key: key);
|
this.useGrayBoxPlaceholder = false,
|
||||||
|
this.isSelected = false,
|
||||||
@override
|
this.multiselectEnabled = false,
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
this.onDeselect,
|
||||||
var box = Hive.box(userInfoBox);
|
this.onSelect,
|
||||||
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
}) : super(key: key);
|
||||||
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
|
||||||
var isMultiSelectEnable =
|
@override
|
||||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
var box = Hive.box(userInfoBox);
|
||||||
|
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||||
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||||
if (selectedAsset.contains(asset)) {
|
|
||||||
return Icon(
|
|
||||||
Icons.check_circle,
|
Widget buildSelectionIcon(AssetResponseDto asset) {
|
||||||
color: Theme.of(context).primaryColor,
|
if (isSelected) {
|
||||||
);
|
return Icon(
|
||||||
} else {
|
Icons.check_circle,
|
||||||
return const Icon(
|
color: Theme.of(context).primaryColor,
|
||||||
Icons.circle_outlined,
|
);
|
||||||
color: Colors.white,
|
} else {
|
||||||
);
|
return const Icon(
|
||||||
}
|
Icons.circle_outlined,
|
||||||
}
|
color: Colors.white,
|
||||||
|
);
|
||||||
return GestureDetector(
|
}
|
||||||
onTap: () {
|
}
|
||||||
debugPrint("View ${asset.id}");
|
|
||||||
if (isMultiSelectEnable &&
|
return GestureDetector(
|
||||||
selectedAsset.contains(asset) &&
|
onTap: () {
|
||||||
selectedAsset.length == 1) {
|
if (multiselectEnabled) {
|
||||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
if (isSelected) {
|
||||||
} else if (isMultiSelectEnable &&
|
onDeselect?.call();
|
||||||
selectedAsset.contains(asset) &&
|
} else {
|
||||||
selectedAsset.length > 1) {
|
onSelect?.call();
|
||||||
ref
|
}
|
||||||
.watch(homePageStateProvider.notifier)
|
} else {
|
||||||
.removeSingleSelectedItem(asset);
|
AutoRouter.of(context).push(
|
||||||
} else if (isMultiSelectEnable && !selectedAsset.contains(asset)) {
|
GalleryViewerRoute(
|
||||||
ref
|
assetList: assetList,
|
||||||
.watch(homePageStateProvider.notifier)
|
asset: asset,
|
||||||
.addSingleSelectedItem(asset);
|
),
|
||||||
} else {
|
);
|
||||||
AutoRouter.of(context).push(
|
}
|
||||||
GalleryViewerRoute(
|
},
|
||||||
assetList: assetList,
|
onLongPress: () {
|
||||||
asset: asset,
|
onSelect?.call();
|
||||||
),
|
HapticFeedback.heavyImpact();
|
||||||
);
|
},
|
||||||
}
|
child: Hero(
|
||||||
},
|
tag: asset.id,
|
||||||
onLongPress: () {
|
child: Stack(
|
||||||
// Enable multi select function
|
children: [
|
||||||
ref.watch(homePageStateProvider.notifier).enableMultiSelect({asset});
|
Container(
|
||||||
HapticFeedback.heavyImpact();
|
decoration: BoxDecoration(
|
||||||
},
|
border: multiselectEnabled && isSelected
|
||||||
child: Hero(
|
? Border.all(
|
||||||
tag: asset.id,
|
color: Theme.of(context).primaryColorLight,
|
||||||
child: Stack(
|
width: 10,
|
||||||
children: [
|
)
|
||||||
Container(
|
: const Border(),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
border: isMultiSelectEnable && selectedAsset.contains(asset)
|
child: CachedNetworkImage(
|
||||||
? Border.all(
|
cacheKey: 'thumbnail-image-${asset.id}',
|
||||||
color: Theme.of(context).primaryColorLight,
|
width: 300,
|
||||||
width: 10,
|
height: 300,
|
||||||
)
|
memCacheHeight: 200,
|
||||||
: const Border(),
|
maxWidthDiskCache: 200,
|
||||||
),
|
maxHeightDiskCache: 200,
|
||||||
child: CachedNetworkImage(
|
fit: BoxFit.cover,
|
||||||
cacheKey: asset.id,
|
imageUrl: thumbnailRequestUrl,
|
||||||
cacheManager: cacheManager,
|
httpHeaders: {
|
||||||
width: 300,
|
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||||
height: 300,
|
},
|
||||||
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
fit: BoxFit.cover,
|
progressIndicatorBuilder: (context, url, downloadProgress) {
|
||||||
imageUrl: thumbnailRequestUrl,
|
if (useGrayBoxPlaceholder) {
|
||||||
httpHeaders: {
|
return const DecoratedBox(
|
||||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
decoration: BoxDecoration(color: Colors.grey),
|
||||||
},
|
);
|
||||||
fadeInDuration: const Duration(milliseconds: 250),
|
}
|
||||||
progressIndicatorBuilder: (context, url, downloadProgress) =>
|
return Transform.scale(
|
||||||
Transform.scale(
|
scale: 0.2,
|
||||||
scale: 0.2,
|
child: CircularProgressIndicator(
|
||||||
child: CircularProgressIndicator(
|
value: downloadProgress.progress,
|
||||||
value: downloadProgress.progress,
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
debugPrint("Error getting thumbnail $url = $error");
|
debugPrint("Error getting thumbnail $url = $error");
|
||||||
return Icon(
|
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
|
||||||
Icons.image_not_supported_outlined,
|
|
||||||
color: Theme.of(context).primaryColor,
|
return Icon(
|
||||||
);
|
Icons.image_not_supported_outlined,
|
||||||
},
|
color: Theme.of(context).primaryColor,
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
if (isMultiSelectEnable)
|
),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.all(3.0),
|
if (multiselectEnabled)
|
||||||
child: Align(
|
Padding(
|
||||||
alignment: Alignment.topLeft,
|
padding: const EdgeInsets.all(3.0),
|
||||||
child: _buildSelectionIcon(asset),
|
child: Align(
|
||||||
),
|
alignment: Alignment.topLeft,
|
||||||
),
|
child: buildSelectionIcon(asset),
|
||||||
if (showStorageIndicator)
|
),
|
||||||
Positioned(
|
),
|
||||||
right: 10,
|
if (showStorageIndicator)
|
||||||
bottom: 5,
|
Positioned(
|
||||||
child: Icon(
|
right: 10,
|
||||||
(deviceId != asset.deviceId)
|
bottom: 5,
|
||||||
? Icons.cloud_done_outlined
|
child: Icon(
|
||||||
: Icons.photo_library_rounded,
|
(deviceId != asset.deviceId)
|
||||||
color: Colors.white,
|
? Icons.cloud_done_outlined
|
||||||
size: 18,
|
: Icons.photo_library_rounded,
|
||||||
),
|
color: Colors.white,
|
||||||
)
|
size: 18,
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
if (asset.type != AssetTypeEnum.IMAGE)
|
||||||
);
|
Positioned(
|
||||||
}
|
top: 5,
|
||||||
}
|
right: 5,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
asset.duration.toString().substring(0, 7),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.play_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
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:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
|
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
|
||||||
|
|
||||||
class ControlBottomAppBar extends ConsumerWidget {
|
class ControlBottomAppBar extends ConsumerWidget {
|
||||||
const ControlBottomAppBar({Key? key}) : super(key: key);
|
final Function onShare;
|
||||||
|
final Function onDelete;
|
||||||
|
|
||||||
|
const ControlBottomAppBar(
|
||||||
|
{Key? key, required this.onShare, required this.onDelete})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -36,7 +40,9 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return const DeleteDialog();
|
return DeleteDialog(
|
||||||
|
onDelete: onDelete,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -45,14 +51,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
iconData: Icons.share,
|
iconData: Icons.share,
|
||||||
label: "control_bottom_app_bar_share".tr(),
|
label: "control_bottom_app_bar_share".tr(),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final homePageState = ref.watch(homePageStateProvider);
|
onShare();
|
||||||
ref.watch(homePageStateProvider.notifier).shareAssets(
|
|
||||||
homePageState.selectedItems.toList(),
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.disableMultiSelect();
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
class DailyTitleText extends ConsumerWidget {
|
|
||||||
const DailyTitleText({
|
|
||||||
Key? key,
|
|
||||||
required this.isoDate,
|
|
||||||
required this.assetGroup,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
final String isoDate;
|
|
||||||
final List<AssetResponseDto> assetGroup;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
var currentYear = DateTime.now().year;
|
|
||||||
var groupYear = DateTime.parse(isoDate).year;
|
|
||||||
var formatDateTemplate = currentYear == groupYear
|
|
||||||
? "daily_title_text_date".tr()
|
|
||||||
: "daily_title_text_date_year".tr();
|
|
||||||
var dateText =
|
|
||||||
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
|
||||||
var isMultiSelectEnable =
|
|
||||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
|
||||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
|
||||||
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
|
|
||||||
|
|
||||||
void _handleTitleIconClick() {
|
|
||||||
if (isMultiSelectEnable &&
|
|
||||||
selectedDateGroup.contains(dateText) &&
|
|
||||||
selectedDateGroup.length == 1 &&
|
|
||||||
selectedItems.length <= assetGroup.length) {
|
|
||||||
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
|
|
||||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
|
||||||
} else if (isMultiSelectEnable &&
|
|
||||||
selectedDateGroup.contains(dateText) &&
|
|
||||||
selectedItems.length != assetGroup.length) {
|
|
||||||
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.removeSelectedDateGroup(dateText);
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.removeMultipleSelectedItem(assetGroup);
|
|
||||||
} else if (isMultiSelectEnable &&
|
|
||||||
selectedDateGroup.contains(dateText) &&
|
|
||||||
selectedDateGroup.length > 1) {
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.removeSelectedDateGroup(dateText);
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.removeMultipleSelectedItem(assetGroup);
|
|
||||||
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.addSelectedDateGroup(dateText);
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.addMultipleSelectedItems(assetGroup);
|
|
||||||
} else {
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.enableMultiSelect(assetGroup.toSet());
|
|
||||||
ref
|
|
||||||
.watch(homePageStateProvider.notifier)
|
|
||||||
.addSelectedDateGroup(dateText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
top: 29.0,
|
|
||||||
bottom: 29.0,
|
|
||||||
left: 12.0,
|
|
||||||
right: 12.0,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
dateText,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _handleTitleIconClick,
|
|
||||||
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
|
|
||||||
? Icon(
|
|
||||||
Icons.check_circle_rounded,
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.check_circle_outline_rounded,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
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:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
|
||||||
|
|
||||||
class DeleteDialog extends ConsumerWidget {
|
class DeleteDialog extends ConsumerWidget {
|
||||||
const DeleteDialog({Key? key}) : super(key: key);
|
final Function onDelete;
|
||||||
|
|
||||||
|
const DeleteDialog({Key? key, required this.onDelete}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final homePageState = ref.watch(homePageStateProvider);
|
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: Colors.grey[200],
|
// backgroundColor: Colors.grey[200],
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
title: const Text("delete_dialog_title").tr(),
|
title: const Text("delete_dialog_title").tr(),
|
||||||
content: const Text("delete_dialog_alert").tr(),
|
content: const Text("delete_dialog_alert").tr(),
|
||||||
@@ -21,23 +20,25 @@ class DeleteDialog extends ConsumerWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: Text(
|
||||||
"delete_dialog_cancel",
|
"delete_dialog_cancel",
|
||||||
style: TextStyle(color: Colors.blueGrey),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref
|
onDelete();
|
||||||
.watch(assetProvider.notifier)
|
|
||||||
.deleteAssets(homePageState.selectedItems);
|
|
||||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
|
||||||
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
"delete_dialog_ok",
|
"delete_dialog_ok",
|
||||||
style: TextStyle(color: Colors.red[400]),
|
style: TextStyle(
|
||||||
|
color: Colors.red[400],
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
|
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
// ignore: must_be_immutable
|
|
||||||
class ImageGrid extends ConsumerWidget {
|
|
||||||
final List<AssetResponseDto> assetGroup;
|
|
||||||
final List<AssetResponseDto> sortedAssetGroup;
|
|
||||||
final int tilesPerRow;
|
|
||||||
final bool showStorageIndicator;
|
|
||||||
final BaseCacheManager? cacheManager;
|
|
||||||
|
|
||||||
ImageGrid({
|
|
||||||
Key? key,
|
|
||||||
required this.assetGroup,
|
|
||||||
required this.sortedAssetGroup,
|
|
||||||
this.cacheManager,
|
|
||||||
this.tilesPerRow = 4,
|
|
||||||
this.showStorageIndicator = true,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
List<AssetResponseDto> imageSortedList = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return SliverGrid(
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: tilesPerRow,
|
|
||||||
crossAxisSpacing: 5.0,
|
|
||||||
mainAxisSpacing: 5,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
|
||||||
(BuildContext context, int index) {
|
|
||||||
var assetType = assetGroup[index].type;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {},
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
ThumbnailImage(
|
|
||||||
cacheManager: cacheManager,
|
|
||||||
asset: assetGroup[index],
|
|
||||||
assetList: sortedAssetGroup,
|
|
||||||
showStorageIndicator: showStorageIndicator,
|
|
||||||
),
|
|
||||||
if (assetType != AssetTypeEnum.IMAGE)
|
|
||||||
Positioned(
|
|
||||||
top: 5,
|
|
||||||
right: 5,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
assetGroup[index].duration.toString().substring(0, 7),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
childCount: assetGroup.length,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
||||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class HomePage extends HookConsumerWidget {
|
class HomePage extends HookConsumerWidget {
|
||||||
@@ -25,21 +21,9 @@ class HomePage extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
final cacheService = ref.watch(cacheServiceProvider);
|
var renderList = ref.watch(renderListProvider);
|
||||||
|
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
|
||||||
ScrollController scrollController = useScrollController();
|
final selection = useState(<AssetResponseDto>{});
|
||||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
|
||||||
List<Widget> imageGridGroup = [];
|
|
||||||
var isMultiSelectEnable =
|
|
||||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
|
||||||
var homePageState = ref.watch(homePageStateProvider);
|
|
||||||
List<AssetResponseDto> sortedAssetList = [];
|
|
||||||
// set sorted List
|
|
||||||
for (var group in assetGroupByDateTime.values) {
|
|
||||||
for (var value in group) {
|
|
||||||
sortedAssetList.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -55,101 +39,61 @@ class HomePage extends HookConsumerWidget {
|
|||||||
ref.read(assetProvider.notifier).getAllAsset();
|
ref.read(assetProvider.notifier).getAllAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildSelectedItemCountIndicator() {
|
Widget buildBody() {
|
||||||
return DisableMultiSelectButton(
|
void selectionListener(
|
||||||
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
bool multiselect,
|
||||||
selectedItemCount: homePageState.selectedItems.length,
|
Set<AssetResponseDto> selectedAssets,
|
||||||
);
|
) {
|
||||||
}
|
multiselectEnabled.state = multiselect;
|
||||||
|
selection.value = selectedAssets;
|
||||||
Widget _buildBody() {
|
|
||||||
if (assetGroupByDateTime.isNotEmpty) {
|
|
||||||
int? lastMonth;
|
|
||||||
|
|
||||||
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
|
|
||||||
try {
|
|
||||||
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
|
||||||
int currentMonth = parseDateGroup.month;
|
|
||||||
|
|
||||||
if (lastMonth != null) {
|
|
||||||
if (currentMonth - lastMonth! != 0) {
|
|
||||||
imageGridGroup.add(
|
|
||||||
MonthlyTitleText(
|
|
||||||
isoDate: dateGroup,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
imageGridGroup.add(
|
|
||||||
DailyTitleText(
|
|
||||||
key: Key('${dateGroup.toString()}title'),
|
|
||||||
isoDate: dateGroup,
|
|
||||||
assetGroup: immichAssetList,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
imageGridGroup.add(
|
|
||||||
ImageGrid(
|
|
||||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
|
||||||
assetGroup: immichAssetList,
|
|
||||||
sortedAssetGroup: sortedAssetList,
|
|
||||||
tilesPerRow:
|
|
||||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
|
||||||
showStorageIndicator: appSettingService
|
|
||||||
.getSetting(AppSettingsEnum.storageIndicator),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
lastMonth = currentMonth;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(
|
|
||||||
"[ERROR] Cannot parse $dateGroup - Wrong create date format : ${immichAssetList.map((asset) => asset.createdAt).toList()}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildSliverAppBar() {
|
void onShareAssets() {
|
||||||
return isMultiSelectEnable
|
ref.watch(shareServiceProvider).shareAssets(selection.value.toList());
|
||||||
? const SliverToBoxAdapter(
|
multiselectEnabled.state = false;
|
||||||
child: SizedBox(
|
}
|
||||||
height: 70,
|
|
||||||
child: null,
|
void onDelete() {
|
||||||
),
|
ref.watch(assetProvider.notifier).deleteAssets(selection.value);
|
||||||
)
|
multiselectEnabled.state = false;
|
||||||
: ImmichSliverAppBar(
|
|
||||||
onPopBack: reloadAllAsset,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: !isMultiSelectEnable,
|
bottom: !multiselectEnabled.state,
|
||||||
top: !isMultiSelectEnable,
|
top: !multiselectEnabled.state,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
CustomScrollView(
|
CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildSliverAppBar(),
|
multiselectEnabled.state
|
||||||
|
? const SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 70,
|
||||||
|
child: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ImmichSliverAppBar(
|
||||||
|
onPopBack: reloadAllAsset,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
||||||
child: DraggableScrollbar.semicircle(
|
child: ImmichAssetGrid(
|
||||||
backgroundColor: Theme.of(context).hintColor,
|
renderList: renderList,
|
||||||
controller: scrollController,
|
assetsPerRow:
|
||||||
heightScrollThumb: 48.0,
|
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
child: CustomScrollView(
|
showStorageIndicator: appSettingService
|
||||||
controller: scrollController,
|
.getSetting(AppSettingsEnum.storageIndicator),
|
||||||
slivers: [
|
listener: selectionListener,
|
||||||
...imageGridGroup,
|
selectionActive: multiselectEnabled.state,
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isMultiSelectEnable) ...[
|
if (multiselectEnabled.state) ...[
|
||||||
_buildSelectedItemCountIndicator(),
|
ControlBottomAppBar(
|
||||||
const ControlBottomAppBar(),
|
onShare: onShareAssets,
|
||||||
|
onDelete: onDelete,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -158,7 +102,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
drawer: const ProfileDrawer(),
|
drawer: const ProfileDrawer(),
|
||||||
body: _buildBody(),
|
body: buildBody(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
@@ -15,6 +18,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
this._deviceInfoService,
|
this._deviceInfoService,
|
||||||
this._backupService,
|
this._backupService,
|
||||||
this._apiService,
|
this._apiService,
|
||||||
|
this._assetCacheService,
|
||||||
|
this._albumCacheService,
|
||||||
|
this._sharedAlbumCacheService,
|
||||||
) : super(
|
) : super(
|
||||||
AuthenticationState(
|
AuthenticationState(
|
||||||
deviceId: "",
|
deviceId: "",
|
||||||
@@ -41,6 +47,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
final DeviceInfoService _deviceInfoService;
|
final DeviceInfoService _deviceInfoService;
|
||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
|
final AssetCacheService _assetCacheService;
|
||||||
|
final AlbumCacheService _albumCacheService;
|
||||||
|
final SharedAlbumCacheService _sharedAlbumCacheService;
|
||||||
|
|
||||||
Future<bool> login(
|
Future<bool> login(
|
||||||
String email,
|
String email,
|
||||||
@@ -120,6 +129,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
.delete(savedLoginInfoKey);
|
.delete(savedLoginInfoKey);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
HapticFeedback.vibrate();
|
||||||
debugPrint("Error logging in $e");
|
debugPrint("Error logging in $e");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -151,7 +161,23 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
Future<bool> logout() async {
|
Future<bool> logout() async {
|
||||||
Hive.box(userInfoBox).delete(accessTokenKey);
|
Hive.box(userInfoBox).delete(accessTokenKey);
|
||||||
state = state.copyWith(isAuthenticated: false);
|
state = state.copyWith(isAuthenticated: false);
|
||||||
|
_assetCacheService.invalidate();
|
||||||
|
_albumCacheService.invalidate();
|
||||||
|
_sharedAlbumCacheService.invalidate();
|
||||||
|
|
||||||
|
// Remove login info from local storage
|
||||||
|
var loginInfo =
|
||||||
|
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
|
||||||
|
if (loginInfo != null) {
|
||||||
|
loginInfo.email = "";
|
||||||
|
loginInfo.password = "";
|
||||||
|
loginInfo.isSaveLogin = false;
|
||||||
|
|
||||||
|
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
|
||||||
|
savedLoginInfoKey,
|
||||||
|
loginInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,5 +223,8 @@ final authenticationProvider =
|
|||||||
ref.watch(deviceInfoServiceProvider),
|
ref.watch(deviceInfoServiceProvider),
|
||||||
ref.watch(backupServiceProvider),
|
ref.watch(backupServiceProvider),
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
|
ref.watch(assetCacheServiceProvider),
|
||||||
|
ref.watch(albumCacheServiceProvider),
|
||||||
|
ref.watch(sharedAlbumCacheServiceProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ class ChangePasswordButton extends ConsumerWidget {
|
|||||||
return ElevatedButton(
|
return ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
visualDensity: VisualDensity.standard,
|
visualDensity: VisualDensity.standard,
|
||||||
primary: Theme.of(context).primaryColor,
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
onPrimary: Colors.grey[50],
|
foregroundColor: Colors.grey[50],
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -203,8 +203,8 @@ class LoginButton extends ConsumerWidget {
|
|||||||
return ElevatedButton(
|
return ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
visualDensity: VisualDensity.standard,
|
visualDensity: VisualDensity.standard,
|
||||||
primary: Theme.of(context).primaryColor,
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
onPrimary: Colors.grey[50],
|
foregroundColor: Colors.grey[50],
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
||||||
),
|
),
|
||||||
@@ -228,7 +228,7 @@ class LoginButton extends ConsumerWidget {
|
|||||||
AutoRouter.of(context).push(const ChangePasswordRoute());
|
AutoRouter.of(context).push(const ChangePasswordRoute());
|
||||||
} else {
|
} else {
|
||||||
ref.watch(backupProvider.notifier).resumeBackup();
|
ref.watch(backupProvider.notifier).resumeBackup();
|
||||||
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
AutoRouter.of(context).replace(const TabControllerRoute());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user