Compare commits
75 Commits
v1.10.0_15
...
v1.17.0_25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3032f74a4 | ||
|
|
58ec7553ea | ||
|
|
357f7d1c31 | ||
|
|
e6d30d72fa | ||
|
|
355038a91a | ||
|
|
97d9b80baa | ||
|
|
b6814fad57 | ||
|
|
7586c65103 | ||
|
|
633170d743 | ||
|
|
c5be7827c3 | ||
|
|
e84c705e31 | ||
|
|
36162509e0 | ||
|
|
76bf1c0379 | ||
|
|
32b847c26e | ||
|
|
a45d6fdf57 | ||
|
|
c071e64a7e | ||
|
|
663f12851e | ||
|
|
c4ef523564 | ||
|
|
992f792c0a | ||
|
|
97611fa057 | ||
|
|
32240777c3 | ||
|
|
6065ff8caa | ||
|
|
8db073941d | ||
|
|
5e281b44e9 | ||
|
|
142ede350e | ||
|
|
a2e1d4caa2 | ||
|
|
5f00d8b9c6 | ||
|
|
2e85e18020 | ||
|
|
40a8115101 | ||
|
|
d02b97e1c1 | ||
|
|
485b152beb | ||
|
|
c918f5b001 | ||
|
|
cca2f7d178 | ||
|
|
baf533de35 | ||
|
|
dfc0d6eee7 | ||
|
|
7948cb8110 | ||
|
|
568436f188 | ||
|
|
04b59318f9 | ||
|
|
1a3d05ffc3 | ||
|
|
2f2db74d73 | ||
|
|
ef097d15dd | ||
|
|
caaa474c23 | ||
|
|
63bebd92e0 | ||
|
|
ad36b8b10f | ||
|
|
18c22d2a6c | ||
|
|
73024edba9 | ||
|
|
a360c0a3d7 | ||
|
|
34657f820f | ||
|
|
8840911f22 | ||
|
|
4aa66f4156 | ||
|
|
799a1c99f2 | ||
|
|
c4247bfea3 | ||
|
|
1e3464fe47 | ||
|
|
b7603fd150 | ||
|
|
517a3363d6 | ||
|
|
3511b69fc8 | ||
|
|
e6efc61b3b | ||
|
|
360c1d9a15 | ||
|
|
e3449f9c8f | ||
|
|
a8e723d722 | ||
|
|
d116234523 | ||
|
|
dce2bc7508 | ||
|
|
2bf764f560 | ||
|
|
587b77e70b | ||
|
|
53cd9fd8bf | ||
|
|
a8220172f8 | ||
|
|
397f8c70b4 | ||
|
|
68ff5377b0 | ||
|
|
b359dc3cb6 | ||
|
|
5b036067ed | ||
|
|
b9f38162d5 | ||
|
|
ab6909bfbd | ||
|
|
53c3c916a6 | ||
|
|
6924aa5eb1 | ||
|
|
a3b45d62b6 |
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -16,10 +16,10 @@ Note: Please search to see if an issue already exists for the bug you encountere
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Task List**
|
||||
[ ] I have read thoroughly the README setup and installation instructions.
|
||||
[ ] If my setup is different, I have included my docker-compose file.
|
||||
[ ] I have included my redacted `.env` file.
|
||||
[ ] I have included information on my machine, and environment.
|
||||
- [ ] I have read thoroughly the README setup and installation instructions.
|
||||
- [ ] If my setup is different, I have included my docker-compose file.
|
||||
- [ ] I have included my redacted `.env` file.
|
||||
- [ ] I have included information on my machine, and environment.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
54
.github/workflows/build_push_docker_latest.yml
vendored
@@ -4,17 +4,16 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build_and_push_server_latest:
|
||||
# This image include both the server and microservices - the two containers can be slitted into separated
|
||||
# service with its coressponding entry file.
|
||||
build_and_push_server_monorepo_latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# ref: "main" # branch
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
@@ -27,23 +26,22 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Immich
|
||||
- name: Build and push Immich Mono Repo
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: |
|
||||
altran1502/immich-server:latest
|
||||
|
||||
build_and_push_microservice_latest:
|
||||
build_and_push_machine_learning_latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# ref: "main" # branch
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
@@ -56,15 +54,15 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Microservices
|
||||
- name: Build and Push Machine Learning
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./microservices
|
||||
file: ./microservices/Dockerfile
|
||||
context: ./machine-learning
|
||||
file: ./machine-learning/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: |
|
||||
altran1502/immich-microservices:latest
|
||||
altran1502/immich-machine-learning:latest
|
||||
|
||||
build_and_push_web_latest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -72,7 +70,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# ref: "main" # branch
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
@@ -91,6 +88,33 @@ jobs:
|
||||
file: ./web/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
target: prod
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: |
|
||||
altran1502/immich-web:latest
|
||||
|
||||
build_and_push_nginx_latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Proxy
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
altran1502/immich-proxy:latest
|
||||
|
||||
122
.github/workflows/build_push_docker_staging.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
name: Build and Push Docker Image - Staging
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# This image include both the server and microservices - the two containers can be slitted into separated
|
||||
# service with its coressponding entry file.
|
||||
build_and_push_server_monorepo_staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Immich Mono Repo
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-server:staging
|
||||
|
||||
build_and_push_machine_learning_staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Machine Learning
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./machine-learning
|
||||
file: ./machine-learning/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64
|
||||
push: ${{ github.event_name == 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-machine-learning:staging
|
||||
|
||||
build_and_push_web_staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Web
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
target: prod
|
||||
push: ${{ github.event_name == 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-web:staging
|
||||
|
||||
build_and_push_nginx_staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Proxy
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-proxy:staging
|
||||
62
.github/workflows/build_push_server_release.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_and_push_server_release:
|
||||
build_and_push_server_monorepo_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -43,44 +43,40 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-server:${{ steps.previoustag.outputs.tag }}
|
||||
altran1502/immich-server:release
|
||||
|
||||
build_and_push_microservice_release:
|
||||
build_and_push_machine_learning_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "Get Previous tag"
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-microservices release
|
||||
- name: Build and Push Machine Learning
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./microservices
|
||||
file: ./microservices/Dockerfile
|
||||
context: ./machine-learning
|
||||
file: ./machine-learning/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: |
|
||||
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }}
|
||||
altran1502/immich-machine-learning:${{ steps.previoustag.outputs.tag }}
|
||||
altran1502/immich-machine-learning:release
|
||||
|
||||
build_and_push_web_release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -120,3 +116,43 @@ jobs:
|
||||
target: prod
|
||||
tags: |
|
||||
altran1502/immich-web:${{ steps.previoustag.outputs.tag }}
|
||||
altran1502/immich-web:release
|
||||
|
||||
build_and_push_nginx_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "Get Previous tag"
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-proxy release
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-proxy:release
|
||||
altran1502/immich-proxy:${{ steps.previoustag.outputs.tag }}
|
||||
|
||||
17
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Test
|
||||
on:
|
||||
pull_request:
|
||||
push: { branches: master }
|
||||
|
||||
jobs:
|
||||
test-server-e2e:
|
||||
name: Run test suite
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Run Immich Server 2E2 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
|
||||
10
Makefile
@@ -1,20 +1,20 @@
|
||||
dev:
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
dev-update:
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
dev-scale:
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans
|
||||
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
|
||||
stage:
|
||||
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
|
||||
|
||||
test-e2e:
|
||||
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich_server_test
|
||||
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich_server_test --remove-orphans
|
||||
|
||||
prod:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
prod-scale:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --scale immich_microservices=3 --remove-orphans
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||
71
README.md
@@ -8,22 +8,24 @@
|
||||
<img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/>
|
||||
</a>
|
||||
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
|
||||
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Server Docker&logo=docker&labelColor=ececec" />
|
||||
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" />
|
||||
</a>
|
||||
<a href="https://discord.gg/D8JsnBEuKb">
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<img src="design/immich-logo.svg" width="200" title="Immich Logo">
|
||||
<img src="design/feature-panel.png" title="Immich Logo">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
# Immich
|
||||
|
||||
Self-hosted photo and video backup solution directly from your mobile phone.
|
||||
**High performance self-hosted photo and video backup solution.**
|
||||
|
||||

|
||||
|
||||
@@ -31,7 +33,7 @@ Loading ~4000 images/videos
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Mobile client
|
||||
### Mobile
|
||||
<p align="left">
|
||||
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
|
||||
<img src="design/backup-screen.png" width="150" title="Backup Setting Info">
|
||||
@@ -42,9 +44,10 @@ Loading ~4000 images/videos
|
||||
<img src="design/nsc6.png" width="150" title="EXIF Info">
|
||||
</p>
|
||||
|
||||
### Web client
|
||||
<p align="center">
|
||||
<img src="design/dashboard_photos.jpeg" width="100%" title="Home Dashboard">
|
||||
### Web
|
||||
<p align="left">
|
||||
<img src="design/web-home.jpeg" width="49%" title="Home Dashboard">
|
||||
<img src="design/web-detail.jpeg" width="49%" title="Detail">
|
||||
</p>
|
||||
|
||||
# Note
|
||||
@@ -53,26 +56,22 @@ Loading ~4000 images/videos
|
||||
|
||||
This project is under heavy development, there will be continuous functions, features and api changes.
|
||||
|
||||
# Features
|
||||
# Features
|
||||
|
||||
| | Mobile | Web |
|
||||
| - | - | - |
|
||||
| Upload and view videos and photos | Yes | Yes
|
||||
| Auto backup when app is opened | Yes | N/A
|
||||
| Selective album(s) for backup | Yes | N/A
|
||||
| Download photos and videos to local device | Yes | Yes
|
||||
| Multi-user support | Yes | Yes
|
||||
| Shared Albums | Yes | No
|
||||
| Quick navigation with draggable scrollbar | Yes | Yes
|
||||
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
|
||||
| Metadata view (EXIF, map) | Yes | Yes
|
||||
| Search by metadata, objects and image tags | Yes | No
|
||||
| Administrative functions (user management) | No | Yes
|
||||
|
||||
- Upload and view assets (videos/images).
|
||||
- Auto Backup.
|
||||
- Download asset to local device.
|
||||
- Multi-user supported.
|
||||
- Quick navigation with drag scroll bar.
|
||||
- Support HEIC/HEIF Backup.
|
||||
- Extract and display EXIF info.
|
||||
- Real-time render from multi-device upload event.
|
||||
- Image Tagging/Classification based on ImageNet dataset
|
||||
- Object detection based on COCO SSD.
|
||||
- Search assets based on tags and exif data (lens, make, model, orientation)
|
||||
- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
|
||||
- Show asset's location information on map (OpenStreetMap).
|
||||
- Show curated places on the search page
|
||||
- Show curated objects on the search page
|
||||
- Shared album with users on the same server
|
||||
- Selective backup - albums can be included and excluded during the backup process.
|
||||
- Web interface is available for administrative tasks (creating new users) and viewing assets on the server - additional features are coming.
|
||||
|
||||
# System Requirement
|
||||
|
||||
@@ -95,7 +94,7 @@ You can use docker compose for development and testing out the application, ther
|
||||
3. **PostgreSQL** - Main database of the application
|
||||
4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
|
||||
5. **Nginx** - Load balancing and optimized file uploading.
|
||||
6. **TensorFlow** - Object Detection and Image Classification.
|
||||
6. **TensorFlow** - Object Detection (COCO SSD) and Image Classification (ImageNet).
|
||||
|
||||
## Step 1: Populate .env file
|
||||
|
||||
@@ -144,8 +143,8 @@ MAPBOX_KEY=
|
||||
# This is the URL of your vm/server where you host Immich, so that the web frontend
|
||||
# know where can it make the request to.
|
||||
# For example: If your server IP address is 10.1.11.50, the environment variable will
|
||||
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283
|
||||
VITE_SERVER_ENDPOINT=http://192.168.1.216:2283
|
||||
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283/api
|
||||
VITE_SERVER_ENDPOINT=http://192.168.1.216:2283/api
|
||||
```
|
||||
|
||||
## Step 2: Start the server
|
||||
@@ -168,11 +167,11 @@ To *update* docker-compose with newest image (if you have started the docker-com
|
||||
docker-compose -f ./docker/docker-compose.yml pull && docker-compose -f ./docker/docker-compose.yml up
|
||||
```
|
||||
|
||||
The server will be running at `http://your-ip:2283` through `Nginx`
|
||||
The server will be running at `http://your-ip:2283/api` through `Nginx`
|
||||
|
||||
## Step 3: Register User
|
||||
|
||||
Access the web interface at `http://your-ip:2285` to register an admin account.
|
||||
Access the web interface at `http://your-ip:2283` to register an admin account.
|
||||
|
||||
<p align="left">
|
||||
<img src="design/admin-registration-form.png" width="300" title="Admin Registration">
|
||||
@@ -186,7 +185,11 @@ Additional accounts on the server can be created by the admin account.
|
||||
|
||||
## Step 4: Run mobile app
|
||||
|
||||
The app is distributed on several platforms below.
|
||||
Login the mobile app with your server address
|
||||
|
||||
<p align="left">
|
||||
<img src="design/login-screen.jpeg" width="250" title="Example login screen">
|
||||
<p/>
|
||||
|
||||
## F-Droid
|
||||
You can get the app on F-droid by clicking the image below.
|
||||
@@ -257,5 +260,5 @@ You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
|
||||
`Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`
|
||||
|
||||
Otherwise you can:
|
||||
- edit `docker-compose.yml` file and comment the whole `immich_microservices` service **which will disable machine learning features like object detection and image classification**
|
||||
- edit `docker-compose.yml` file and comment the whole `immich-machine-learning` service **which will disable machine learning features like object detection and image classification**
|
||||
- switch to a different VM/desktop with different architecture.
|
||||
|
||||
|
Before Width: | Height: | Size: 154 KiB |
BIN
design/feature-panel.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
design/login-screen.jpeg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
design/web-admin.jpeg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
design/web-detail.jpeg
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
design/web-home.jpeg
Normal file
|
After Width: | Height: | Size: 206 KiB |
@@ -57,7 +57,7 @@ MAPBOX_KEY=
|
||||
# This is the URL of your vm/server where you host Immich, so that the web frontend
|
||||
# know where can it make the request to.
|
||||
# For example: If your server IP address is 10.1.11.50, the environment variable will
|
||||
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283
|
||||
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283/api
|
||||
# !CAUTION! THERE IS NO FORWARD SLASH AT THE END
|
||||
|
||||
VITE_SERVER_ENDPOINT=
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
DB_HOSTNAME=immich_postgres_test
|
||||
# Database
|
||||
DB_HOSTNAME=immich_postgres_test
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE_NAME=e2e_test
|
||||
|
||||
# Redis
|
||||
REDIS_HOSTNAME=immich_redis_test
|
||||
|
||||
# Upload File Config
|
||||
UPLOAD_LOCATION=./upload
|
||||
|
||||
@@ -13,4 +16,7 @@ JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||
# MAPBOX
|
||||
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
||||
ENABLE_MAPBOX=false
|
||||
MAPBOX_KEY=
|
||||
|
||||
# WEB
|
||||
MAPBOX_KEY=
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
@@ -2,13 +2,11 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
image: immich-server-dev:1.9.0
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3000"
|
||||
command: npm run start:dev immich
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
@@ -20,19 +18,15 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
immich-microservices:
|
||||
image: immich-microservices-dev:1.9.0
|
||||
immich-machine-learning:
|
||||
image: immich-machine-learning-dev:latest
|
||||
build:
|
||||
context: ../microservices
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ../microservices:/usr/src/app
|
||||
- ../machine-learning:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
@@ -41,8 +35,24 @@ services:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
immich-microservices:
|
||||
image: immich-microservices:latest
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev microservices
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- database
|
||||
- immich-server
|
||||
|
||||
immich-web:
|
||||
image: immich-web-dev:1.9.0
|
||||
@@ -54,20 +64,16 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 3002:3002
|
||||
- 3000:3000
|
||||
- 24678:24678
|
||||
volumes:
|
||||
- ../web:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
@@ -83,25 +89,21 @@ services:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
nginx:
|
||||
container_name: proxy_nginx
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- ./settings/nginx-conf:/etc/nginx/conf.d
|
||||
immich-proxy:
|
||||
container_name: immich_proxy
|
||||
image: immich-proxy-dev:latest
|
||||
build:
|
||||
context: ../nginx
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2284:443
|
||||
logging:
|
||||
driver: none
|
||||
networks:
|
||||
- immich-network
|
||||
depends_on:
|
||||
- immich-server
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
immich-network:
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
image: immich-server-dev:1.9.0
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
immich-microservices:
|
||||
image: immich-microservices-dev:1.9.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [ gpu ]
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ../microservices:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- database
|
||||
- immich_server
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: postgres:14
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
PG_DATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
nginx:
|
||||
container_name: proxy_nginx
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- ./settings/nginx-conf:/etc/nginx/conf.d
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2284:443
|
||||
logging:
|
||||
driver: none
|
||||
networks:
|
||||
- immich-network
|
||||
depends_on:
|
||||
- immich-server
|
||||
|
||||
networks:
|
||||
immich-network:
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -2,13 +2,8 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
image: immich-server-staging:latest
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
expose:
|
||||
- "3000"
|
||||
image: altran1502/immich-server:staging
|
||||
entrypoint: ["/bin/sh", "./start-server.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -18,18 +13,25 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
image: immich-microservices-staging:latest
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: Dockerfile
|
||||
image: altran1502/immich-server:staging
|
||||
entrypoint: ["/bin/sh", "./start-microservices.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
restart: always
|
||||
|
||||
immich-machine-learning:
|
||||
image: altran1502/immich-machine-learning:staging
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -38,33 +40,19 @@ services:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-web:
|
||||
image: immich-web-staging:latest
|
||||
image: altran1502/immich-web:staging
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
build:
|
||||
context: ../web
|
||||
dockerfile: Dockerfile
|
||||
target: prod
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 2285:3000
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
@@ -78,28 +66,19 @@ services:
|
||||
PG_DATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
container_name: proxy_nginx
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- ./settings/nginx-conf:/etc/nginx/conf.d
|
||||
immich-proxy:
|
||||
container_name: immich_proxy
|
||||
image: altran1502/immich-proxy:staging
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2284:443
|
||||
logging:
|
||||
driver: none
|
||||
networks:
|
||||
- immich-network
|
||||
depends_on:
|
||||
- immich-server
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
immich-network:
|
||||
volumes:
|
||||
pgdata:
|
||||
pgdata:
|
||||
|
||||
@@ -2,7 +2,7 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server_test:
|
||||
image: immich-server-dev:1.9.0
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
@@ -19,15 +19,10 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich_network_test
|
||||
|
||||
|
||||
redis:
|
||||
container_name: immich_redis_test
|
||||
image: redis:6.2
|
||||
networks:
|
||||
- immich_network_test
|
||||
|
||||
database:
|
||||
container_name: immich_postgres_test
|
||||
@@ -40,13 +35,6 @@ services:
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
PG_DATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- pgdata-test:/var/lib/postgresql/data
|
||||
- /var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich_network_test
|
||||
|
||||
networks:
|
||||
immich_network_test:
|
||||
volumes:
|
||||
pgdata-test:
|
||||
|
||||
@@ -2,10 +2,8 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
image: altran1502/immich-server:latest
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
expose:
|
||||
- "3000"
|
||||
image: altran1502/immich-server:release
|
||||
entrypoint: ["/bin/sh", "./start-server.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -15,15 +13,25 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
image: altran1502/immich-microservices:latest
|
||||
image: altran1502/immich-server:release
|
||||
entrypoint: ["/bin/sh", "./start-microservices.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
restart: always
|
||||
|
||||
immich-machine-learning:
|
||||
image: altran1502/immich-machine-learning:release
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -32,29 +40,19 @@ services:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-web:
|
||||
image: altran1502/immich-web:latest
|
||||
image: altran1502/immich-web:release
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 2285:3000
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
@@ -68,29 +66,18 @@ services:
|
||||
PG_DATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
container_name: proxy_nginx
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- ./settings/nginx-conf:/etc/nginx/conf.d
|
||||
|
||||
immich-proxy:
|
||||
container_name: immich_proxy
|
||||
image: altran1502/immich-proxy:release
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2284:443
|
||||
logging:
|
||||
driver: none
|
||||
networks:
|
||||
- immich-network
|
||||
depends_on:
|
||||
- immich-server
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
immich-network:
|
||||
volumes:
|
||||
pgdata:
|
||||
pgdata:
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# events {
|
||||
# worker_connections 1000;
|
||||
# }
|
||||
|
||||
server {
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gunzip on;
|
||||
|
||||
client_max_body_size 50000M;
|
||||
|
||||
listen 80;
|
||||
access_log off;
|
||||
|
||||
location / {
|
||||
|
||||
# Compression
|
||||
gzip_static on;
|
||||
gzip_min_length 1000;
|
||||
gzip_comp_level 2;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_busy_buffers_size 24k;
|
||||
proxy_buffers 64 4k;
|
||||
proxy_force_ranges on;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_pass http://immich-server:3000;
|
||||
}
|
||||
}
|
||||
@@ -32,4 +32,6 @@ lerna-debug.log*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
upload/
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16-bullseye-slim
|
||||
FROM node:16-bullseye-slim
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
|
||||
RUN apt-get update
|
||||
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
||||
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -7,7 +7,7 @@ export class ImageClassifierController {
|
||||
private readonly imageClassifierService: ImageClassifierService,
|
||||
) { }
|
||||
|
||||
@Post('/tagImage')
|
||||
@Post('/tag-image')
|
||||
async tagImage(@Body('thumbnailPath') thumbnailPath: string) {
|
||||
return await this.imageClassifierService.tagImage(thumbnailPath);
|
||||
}
|
||||
@@ -5,17 +5,17 @@ import { Logger } from '@nestjs/common';
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
await app.listen(3001, () => {
|
||||
await app.listen(3003, () => {
|
||||
if (process.env.NODE_ENV == 'development') {
|
||||
Logger.log(
|
||||
'Running Immich Microservices in DEVELOPMENT environment',
|
||||
'Running Immich Machine Learning in DEVELOPMENT environment',
|
||||
'IMMICH MICROSERVICES',
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV == 'production') {
|
||||
Logger.log(
|
||||
'Running Immich Microservices in PRODUCTION environment',
|
||||
'Running Immich Machine Learning in PRODUCTION environment',
|
||||
'IMMICH MICROSERVICES',
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export class ObjectDetectionController {
|
||||
private readonly objectDetectionService: ObjectDetectionService,
|
||||
) { }
|
||||
|
||||
@Post('/detectObject')
|
||||
@Post('/detect-object')
|
||||
async detectObject(@Body('thumbnailPath') thumbnailPath: string) {
|
||||
return await this.objectDetectionService.detectObject(thumbnailPath);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
devenv/
|
||||
3
machine_learning/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
__pycache__/
|
||||
devenv/
|
||||
app/upload
|
||||
@@ -1,25 +0,0 @@
|
||||
## GPU Build
|
||||
# FROM tensorflow/tensorflow:latest-gpu as gpu
|
||||
|
||||
# WORKDIR /code
|
||||
|
||||
# COPY ./requirements.txt /code/requirements.txt
|
||||
|
||||
# RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
||||
|
||||
# COPY ./app /code/app
|
||||
|
||||
|
||||
## CPU BUILD
|
||||
FROM python:3.8 as cpu
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install ffmpeg libsm6 libxext6 -y
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
COPY ./requirements.txt /code/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
||||
|
||||
COPY ./app /code/app
|
||||
|
Before Width: | Height: | Size: 193 KiB |
@@ -1,37 +0,0 @@
|
||||
from tensorflow.keras.applications import InceptionV3
|
||||
from tensorflow.keras.applications.inception_v3 import preprocess_input, decode_predictions
|
||||
from tensorflow.keras.preprocessing import image
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import cv2
|
||||
IMG_SIZE = 299
|
||||
PREDICTION_MODEL = InceptionV3(weights='imagenet')
|
||||
|
||||
|
||||
def classify_image(image_path: str):
|
||||
img_path = f'./app/{image_path}'
|
||||
# img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
|
||||
|
||||
target_image = cv2.imread(img_path, cv2.IMREAD_UNCHANGED)
|
||||
resized_target_image = cv2.resize(target_image, (IMG_SIZE, IMG_SIZE))
|
||||
|
||||
x = image.img_to_array(resized_target_image)
|
||||
x = np.expand_dims(x, axis=0)
|
||||
x = preprocess_input(x)
|
||||
|
||||
preds = PREDICTION_MODEL.predict(x)
|
||||
result = decode_predictions(preds, top=3)[0]
|
||||
payload = []
|
||||
for _, value, _ in result:
|
||||
payload.append(value)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def warm_up():
|
||||
img_path = f'./app/test.png'
|
||||
img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
|
||||
x = image.img_to_array(img)
|
||||
x = np.expand_dims(x, axis=0)
|
||||
x = preprocess_input(x)
|
||||
PREDICTION_MODEL.predict(x)
|
||||
@@ -1,46 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .object_detection import object_detection
|
||||
from .image_classifier import image_classifier
|
||||
|
||||
from tf2_yolov4.anchors import YOLOV4_ANCHORS
|
||||
from tf2_yolov4.model import YOLOv4
|
||||
|
||||
|
||||
HEIGHT, WIDTH = (640, 960)
|
||||
|
||||
# Warm up model
|
||||
image_classifier.warm_up()
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class TagImagePayload(BaseModel):
|
||||
thumbnail_path: str
|
||||
|
||||
|
||||
@app.post("/tagImage")
|
||||
async def post_root(payload: TagImagePayload):
|
||||
image_path = payload.thumbnail_path
|
||||
|
||||
if image_path[0] == '.':
|
||||
image_path = image_path[2:]
|
||||
|
||||
return image_classifier.classify_image(image_path=image_path)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def test():
|
||||
|
||||
object_detection.run_detection()
|
||||
# image = tf.io.read_file("./app/cars.jpg")
|
||||
# image = tf.image.decode_image(image)
|
||||
# image = tf.image.resize(image, (HEIGHT, WIDTH))
|
||||
# images = tf.expand_dims(image, axis=0) / 255.0
|
||||
|
||||
# model = YOLOv4(
|
||||
# (HEIGHT, WIDTH, 3),
|
||||
# 80,
|
||||
# YOLOV4_ANCHORS,
|
||||
# "darknet",
|
||||
# )
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
|
||||
def run_detection():
|
||||
print("run detection")
|
||||
|
Before Width: | Height: | Size: 345 KiB |
@@ -1,8 +0,0 @@
|
||||
opencv-python==4.5.5.64
|
||||
fastapi>=0.68.0,<0.69.0
|
||||
pydantic>=1.8.0,<2.0.0
|
||||
uvicorn>=0.15.0,<0.16.0
|
||||
tensorflow==2.8.0
|
||||
numpy==1.22.2
|
||||
pillow==9.0.1
|
||||
tf2_yolov4==0.1.0
|
||||
@@ -1,10 +1,45 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
# This file should be version controlled.
|
||||
|
||||
version:
|
||||
revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b
|
||||
revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
channel: stable
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
- platform: android
|
||||
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
- platform: ios
|
||||
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
- platform: linux
|
||||
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
- platform: macos
|
||||
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
- platform: web
|
||||
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
- platform: windows
|
||||
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
|
||||
@@ -24,6 +24,7 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
use_build_context_synchronously: false
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -81,5 +81,4 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'com.android.support:multidex:1.0.3'
|
||||
}
|
||||
|
||||
@@ -23,4 +23,11 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -1,25 +0,0 @@
|
||||
// Generated file.
|
||||
//
|
||||
// If you wish to remove Flutter's multidex support, delete this entire file.
|
||||
//
|
||||
// Modifications to this file should be done in a copy under a different name
|
||||
// as this file may be regenerated.
|
||||
|
||||
package io.flutter.app;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.multidex.MultiDex;
|
||||
|
||||
/**
|
||||
* Extension of {@link android.app.Application}, adding multidex support.
|
||||
*/
|
||||
public class FlutterMultiDexApplication extends Application {
|
||||
@Override
|
||||
@CallSuper
|
||||
protected void attachBaseContext(Context base) {
|
||||
super.attachBaseContext(base);
|
||||
MultiDex.install(this);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ buildscript {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.1.0'
|
||||
classpath 'com.android.tools.build:gradle:7.1.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
* Update to Material Design 3
|
||||
* Fixed back button navigation - no longer return back to the home page
|
||||
@@ -0,0 +1 @@
|
||||
* Added announcement pop-up when a new released is pushed out in Github.
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed crash issue when upload large file on slow network
|
||||
* Updated album to conform with server refactoring of SharedAlbum to Album
|
||||
@@ -0,0 +1,2 @@
|
||||
* Added zoom functionality to the image viewer
|
||||
* Fixed issue with the user is logged out after turning off the app
|
||||
@@ -0,0 +1 @@
|
||||
* Fixed WebSocket endpoint to confirm with the new settings on the server
|
||||
@@ -0,0 +1,3 @@
|
||||
* Fixed app does not resume back up when reopening a closed app
|
||||
* Fixed wrong asset count on the upload page
|
||||
* Added mechanism to change the password of new user on the first login (except Admin)
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed admin is forced to change password upon logging in on mobile app
|
||||
* Fixed change password form validation
|
||||
@@ -0,0 +1 @@
|
||||
* Removed thumbnail generation on mobile - the operation now will be on the server to reduce CPU load and battery usage.
|
||||
@@ -0,0 +1 @@
|
||||
* Hot fix: Restore shared album functionality
|
||||
@@ -0,0 +1 @@
|
||||
* Add information for uploading asset and error indication with error message for each failed upload.
|
||||
@@ -3,5 +3,5 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
|
||||
distributionSha256Sum=0080de8491f0918e4f529a6db6820fa0b9e818ee2386117f4394f95feb1d5583
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||
distributionSha256Sum=cd5c2958a107ee7f0722004a12d0f8559b4564c34daad7df06cffd4d12a426d0
|
||||
@@ -23,6 +23,8 @@ PODS:
|
||||
- Flutter
|
||||
- FMDB (>= 2.7.5)
|
||||
- Toast (4.0.0)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- wakelock (0.0.1):
|
||||
@@ -37,6 +39,7 @@ DEPENDENCIES:
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
|
||||
- wakelock (from `.symlinks/plugins/wakelock/ios`)
|
||||
|
||||
@@ -63,6 +66,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/photo_manager/ios"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/ios"
|
||||
wakelock:
|
||||
@@ -80,6 +85,7 @@ SPEC CHECKSUMS:
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
|
||||
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
@@ -76,5 +76,11 @@
|
||||
<false />
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true />
|
||||
|
||||
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.10.0"
|
||||
version_number: "1.17.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
const String userInfoBox = "immichBoxUserInfo"; // Box
|
||||
const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
|
||||
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
|
||||
|
||||
// Server endpoint
|
||||
const String serverEndpointKey = 'immichBoxServerEndpoint';
|
||||
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
|
||||
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
|
||||
|
||||
// Login Info
|
||||
const String hiveLoginInfoBox = "immichLoginInfoBox";
|
||||
const String savedLoginInfoKey = "immichSavedLoginInfoKey";
|
||||
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
|
||||
const String savedLoginInfoKey = "immichSavedLoginInfoKey"; // Key 1
|
||||
|
||||
// Backup Info
|
||||
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox";
|
||||
const String backupInfoKey = "immichBackupAlbumInfoKey";
|
||||
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox"; // Box
|
||||
const String backupInfoKey = "immichBackupAlbumInfoKey"; // Key 1
|
||||
|
||||
// Github Release Info
|
||||
const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; // Box
|
||||
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
|
||||
|
||||
@@ -4,15 +4,19 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.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/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/release_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/views/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
|
||||
|
||||
import 'constants/hive_box.dart';
|
||||
|
||||
void main() async {
|
||||
@@ -24,6 +28,7 @@ void main() async {
|
||||
await Hive.openBox(userInfoBox);
|
||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
await Hive.openBox(hiveGithubReleaseInfoBox);
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
@@ -35,23 +40,32 @@ void main() async {
|
||||
}
|
||||
|
||||
class ImmichApp extends ConsumerStatefulWidget {
|
||||
const ImmichApp({Key? key}) : super(key: key);
|
||||
const ImmichApp({super.key});
|
||||
|
||||
@override
|
||||
_ImmichAppState createState() => _ImmichAppState();
|
||||
ImmichAppState createState() => ImmichAppState();
|
||||
}
|
||||
|
||||
class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserver {
|
||||
class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
with WidgetsBindingObserver {
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
debugPrint("[APP STATE] resumed");
|
||||
ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed;
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
|
||||
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
|
||||
|
||||
if (isAuthenticated) {
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
ref.watch(assetProvider.notifier).getAllAsset();
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
}
|
||||
|
||||
ref.watch(websocketProvider.notifier).connect();
|
||||
ref.watch(assetProvider.notifier).getAllAsset();
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
|
||||
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
|
||||
|
||||
break;
|
||||
|
||||
@@ -95,6 +109,8 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
|
||||
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Stack(
|
||||
@@ -103,10 +119,12 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
title: 'Immich',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
primarySwatch: Colors.indigo,
|
||||
fontFamily: 'WorkSans',
|
||||
snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
contentTextStyle: TextStyle(fontFamily: 'WorkSans')),
|
||||
scaffoldBackgroundColor: immichBackgroundColor,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: immichBackgroundColor,
|
||||
@@ -117,9 +135,11 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
),
|
||||
),
|
||||
routeInformationParser: _immichRouter.defaultRouteParser(),
|
||||
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
|
||||
routerDelegate: _immichRouter.delegate(
|
||||
navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
|
||||
),
|
||||
const ImmichLoadingOverlay(),
|
||||
const VersionAnnouncementOverlay(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -28,22 +28,26 @@ class ImageViewerPageState {
|
||||
|
||||
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
|
||||
return ImageViewerPageState(
|
||||
downloadAssetStatus: DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
|
||||
downloadAssetStatus:
|
||||
DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
|
||||
factory ImageViewerPageState.fromJson(String source) =>
|
||||
ImageViewerPageState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
|
||||
String toString() =>
|
||||
'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ImageViewerPageState && other.downloadAssetStatus == downloadAssetStatus;
|
||||
return other is ImageViewerPageState &&
|
||||
other.downloadAssetStatus == downloadAssetStatus;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -9,7 +9,9 @@ import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
final ImageViewerService _imageViewerService = ImageViewerService();
|
||||
|
||||
ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle));
|
||||
ImageViewerStateNotifier()
|
||||
: super(ImageViewerPageState(
|
||||
downloadAssetStatus: DownloadAssetStatus.idle));
|
||||
|
||||
void downloadAsset(ImmichAsset asset, BuildContext context) async {
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
|
||||
@@ -40,4 +42,5 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
}
|
||||
|
||||
final imageViewerStateProvider =
|
||||
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(((ref) => ImageViewerStateNotifier()));
|
||||
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(
|
||||
((ref) => ImageViewerStateNotifier()));
|
||||
|
||||
@@ -15,12 +15,14 @@ class ImageViewerService {
|
||||
try {
|
||||
String fileName = p.basename(asset.originalPath);
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
Uri filePath =
|
||||
Uri.parse("$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
|
||||
Uri filePath = Uri.parse(
|
||||
"$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
|
||||
|
||||
var res = await http.get(
|
||||
filePath,
|
||||
headers: {"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"},
|
||||
headers: {
|
||||
"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"
|
||||
},
|
||||
);
|
||||
|
||||
final AssetEntity? entity;
|
||||
@@ -37,14 +39,10 @@ class ImageViewerService {
|
||||
entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
|
||||
}
|
||||
|
||||
if (entity != null) {
|
||||
return true;
|
||||
}
|
||||
return entity != null;
|
||||
} catch (e) {
|
||||
debugPrint("Error saving file $e");
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,77 +9,78 @@ import 'package:latlong2/latlong.dart';
|
||||
class ExifBottomSheet extends ConsumerWidget {
|
||||
final ImmichAssetWithExif assetDetail;
|
||||
|
||||
const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key);
|
||||
const ExifBottomSheet({Key? key, required this.assetDetail})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
_buildMap() {
|
||||
return (assetDetail.exifInfo!.latitude != null && assetDetail.exifInfo!.longitude != null)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Container(
|
||||
height: 150,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
center: LatLng(assetDetail.exifInfo!.latitude!, assetDetail.exifInfo!.longitude!),
|
||||
zoom: 16.0,
|
||||
),
|
||||
layers: [
|
||||
TileLayerOptions(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
attributionBuilder: (_) {
|
||||
return const Text(
|
||||
"© OpenStreetMap",
|
||||
style: TextStyle(fontSize: 10),
|
||||
);
|
||||
},
|
||||
),
|
||||
MarkerLayerOptions(
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(assetDetail.exifInfo!.latitude!, assetDetail.exifInfo!.longitude!),
|
||||
builder: (ctx) => const Image(image: AssetImage('assets/location-pin.png')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Container(
|
||||
height: 150,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
center: LatLng(assetDetail.exifInfo!.latitude!,
|
||||
assetDetail.exifInfo!.longitude!),
|
||||
zoom: 16.0,
|
||||
),
|
||||
layers: [
|
||||
TileLayerOptions(
|
||||
urlTemplate:
|
||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
attributionBuilder: (_) {
|
||||
return const Text(
|
||||
"© OpenStreetMap",
|
||||
style: TextStyle(fontSize: 10),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: Container();
|
||||
MarkerLayerOptions(
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(assetDetail.exifInfo!.latitude!,
|
||||
assetDetail.exifInfo!.longitude!),
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildLocationText() {
|
||||
return (assetDetail.exifInfo!.city != null && assetDetail.exifInfo!.state != null)
|
||||
? Text(
|
||||
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[200], fontWeight: FontWeight.bold),
|
||||
)
|
||||
: Container();
|
||||
return Text(
|
||||
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey[200], fontWeight: FontWeight.bold),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
|
||||
child: ListView(
|
||||
children: [
|
||||
assetDetail.exifInfo?.dateTimeOriginal != null
|
||||
? Text(
|
||||
DateFormat('E, LLL d, y • h:mm a').format(
|
||||
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
||||
Text(
|
||||
DateFormat('E, LLL d, y • h:mm a').format(
|
||||
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Text(
|
||||
@@ -92,82 +93,83 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Location
|
||||
assetDetail.exifInfo?.latitude != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(
|
||||
thickness: 1,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
Text(
|
||||
"LOCATION",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
),
|
||||
_buildMap(),
|
||||
_buildLocationText(),
|
||||
Text(
|
||||
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||
)
|
||||
],
|
||||
if (assetDetail.exifInfo?.latitude != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(
|
||||
thickness: 1,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
Text(
|
||||
"LOCATION",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
),
|
||||
if (assetDetail.exifInfo?.latitude != null &&
|
||||
assetDetail.exifInfo?.longitude != null)
|
||||
_buildMap(),
|
||||
if (assetDetail.exifInfo?.city != null &&
|
||||
assetDetail.exifInfo?.state != null)
|
||||
_buildLocationText(),
|
||||
Text(
|
||||
"${assetDetail.exifInfo?.latitude?.toStringAsFixed(4)}, ${assetDetail.exifInfo?.longitude?.toStringAsFixed(4)}",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
// Detail
|
||||
assetDetail.exifInfo != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(
|
||||
thickness: 1,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"DETAILS",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
textColor: Colors.grey[300],
|
||||
iconColor: Colors.grey[300],
|
||||
leading: const Icon(Icons.image),
|
||||
title: Text(
|
||||
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: assetDetail.exifInfo?.exifImageHeight != null
|
||||
? Text(
|
||||
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ")
|
||||
: Container(),
|
||||
),
|
||||
assetDetail.exifInfo?.make != null
|
||||
? ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
textColor: Colors.grey[300],
|
||||
iconColor: Colors.grey[300],
|
||||
leading: const Icon(Icons.camera),
|
||||
title: Text(
|
||||
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
|
||||
)
|
||||
: Container()
|
||||
],
|
||||
if (assetDetail.exifInfo != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(
|
||||
thickness: 1,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
)
|
||||
: Container()
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"DETAILS",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
textColor: Colors.grey[300],
|
||||
iconColor: Colors.grey[300],
|
||||
leading: const Icon(Icons.image),
|
||||
title: Text(
|
||||
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: assetDetail.exifInfo?.exifImageHeight != null
|
||||
? Text(
|
||||
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ")
|
||||
: null,
|
||||
),
|
||||
if (assetDetail.exifInfo?.make != null)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
textColor: Colors.grey[300],
|
||||
iconColor: Colors.grey[300],
|
||||
leading: const Icon(Icons.camera),
|
||||
title: Text(
|
||||
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / (assetDetail.exifInfo?.exposureTime ?? 1)).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
113
mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
enum _RemoteImageStatus { empty, thumbnail, full }
|
||||
|
||||
class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
late CachedNetworkImageProvider _imageProvider;
|
||||
_RemoteImageStatus _status = _RemoteImageStatus.empty;
|
||||
bool _zoomedIn = false;
|
||||
|
||||
static const int swipeThreshold = 100;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool allowMoving = _status == _RemoteImageStatus.full;
|
||||
|
||||
return PhotoView(
|
||||
imageProvider: _imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: allowMoving ? 1.0 : PhotoViewComputedScale.contained,
|
||||
enablePanAlways: true,
|
||||
scaleStateChangedCallback: _scaleStateChanged,
|
||||
onScaleEnd: _onScaleListener);
|
||||
}
|
||||
|
||||
void _onScaleListener(BuildContext context, ScaleEndDetails details,
|
||||
PhotoViewControllerValue controllerValue) {
|
||||
// Disable swipe events when zoomed in
|
||||
if (_zoomedIn) return;
|
||||
|
||||
if (controllerValue.position.dy > swipeThreshold) {
|
||||
widget.onSwipeDown();
|
||||
} else if (controllerValue.position.dy < -swipeThreshold) {
|
||||
widget.onSwipeUp();
|
||||
}
|
||||
}
|
||||
|
||||
void _scaleStateChanged(PhotoViewScaleState state) {
|
||||
_zoomedIn = state == PhotoViewScaleState.zoomedIn;
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(String url) {
|
||||
return CachedNetworkImageProvider(url,
|
||||
headers: {"Authorization": widget.authToken}, cacheKey: url);
|
||||
}
|
||||
|
||||
void _performStateTransition(
|
||||
_RemoteImageStatus newStatus, CachedNetworkImageProvider provider) {
|
||||
// Transition to same status is forbidden
|
||||
if (_status == newStatus) return;
|
||||
// Transition full -> thumbnail is forbidden
|
||||
if (_status == _RemoteImageStatus.full &&
|
||||
newStatus == _RemoteImageStatus.thumbnail) return;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_status = newStatus;
|
||||
_imageProvider = provider;
|
||||
});
|
||||
}
|
||||
|
||||
void _loadImages() {
|
||||
CachedNetworkImageProvider thumbnailProvider =
|
||||
_authorizedImageProvider(widget.thumbnailUrl);
|
||||
_imageProvider = thumbnailProvider;
|
||||
|
||||
thumbnailProvider
|
||||
.resolve(const ImageConfiguration())
|
||||
.addListener(ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.thumbnail, thumbnailProvider);
|
||||
}));
|
||||
|
||||
CachedNetworkImageProvider fullProvider =
|
||||
_authorizedImageProvider(widget.imageUrl);
|
||||
fullProvider
|
||||
.resolve(const ImageConfiguration())
|
||||
.addListener(ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.full, fullProvider);
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_loadImages();
|
||||
super.initState();
|
||||
}
|
||||
}
|
||||
|
||||
class RemotePhotoView extends StatefulWidget {
|
||||
const RemotePhotoView(
|
||||
{Key? key,
|
||||
required this.thumbnailUrl,
|
||||
required this.imageUrl,
|
||||
required this.authToken,
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp})
|
||||
: super(key: key);
|
||||
|
||||
final String thumbnailUrl;
|
||||
final String imageUrl;
|
||||
final String authToken;
|
||||
|
||||
final void Function() onSwipeDown;
|
||||
final void Function() onSwipeUp;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _RemotePhotoViewState();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -5,7 +7,10 @@ import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
const TopControlAppBar(
|
||||
{Key? key, required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed})
|
||||
{Key? key,
|
||||
required this.asset,
|
||||
required this.onMoreInfoPressed,
|
||||
required this.onDownloadPressed})
|
||||
: super(key: key);
|
||||
|
||||
final ImmichAsset asset;
|
||||
@@ -42,9 +47,11 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
print("favorite");
|
||||
log("favorite");
|
||||
},
|
||||
icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
|
||||
icon: asset.isFavorite
|
||||
? const Icon(Icons.favorite_rounded)
|
||||
: const Icon(Icons.favorite_border_rounded),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
@@ -10,6 +8,7 @@ import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_stat
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.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/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
@@ -21,20 +20,26 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
final String heroTag;
|
||||
final String thumbnailUrl;
|
||||
final ImmichAsset asset;
|
||||
final AssetService _assetService = AssetService();
|
||||
|
||||
ImmichAssetWithExif? assetDetail;
|
||||
|
||||
ImageViewerPage(
|
||||
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
|
||||
: super(key: key);
|
||||
ImageViewerPage({
|
||||
Key? key,
|
||||
required this.imageUrl,
|
||||
required this.heroTag,
|
||||
required this.thumbnailUrl,
|
||||
required this.asset,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
getAssetExif() async {
|
||||
assetDetail = await _assetService.getAssetById(asset.id);
|
||||
assetDetail =
|
||||
await ref.watch(assetServiceProvider).getAssetById(asset.id);
|
||||
}
|
||||
|
||||
showInfo() {
|
||||
@@ -60,75 +65,30 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
asset: asset,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onDownloadPressed: () {
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.downloadAsset(asset, context);
|
||||
},
|
||||
),
|
||||
body: SwipeDetector(
|
||||
onSwipeDown: (_) {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
onSwipeUp: (_) {
|
||||
showInfo();
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Hero(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
child: RemotePhotoView(
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
errorWidget: (context, url, error) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Wrap(
|
||||
spacing: 32,
|
||||
runSpacing: 32,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
"Failed To Render Image - Possibly Corrupted Data",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
child: Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
placeholder: (context, url) {
|
||||
return CachedNetworkImage(
|
||||
cacheKey: thumbnailUrl,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
placeholderFadeInDuration: const Duration(milliseconds: 0),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.error,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
authToken: "Bearer ${box.get(accessTokenKey)}",
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: () => showInfo(),
|
||||
)),
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
const Center(
|
||||
child: DownloadLoadingIndicator(),
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
const Center(
|
||||
child: DownloadLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -21,13 +21,14 @@ class VideoViewerPage extends HookConsumerWidget {
|
||||
final String videoUrl;
|
||||
final ImmichAsset asset;
|
||||
ImmichAssetWithExif? assetDetail;
|
||||
final AssetService _assetService = AssetService();
|
||||
|
||||
VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) : super(key: key);
|
||||
VideoViewerPage({Key? key, required this.videoUrl, required this.asset})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
|
||||
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
|
||||
@@ -44,7 +45,8 @@ class VideoViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
getAssetExif() async {
|
||||
assetDetail = await _assetService.getAssetById(asset.id);
|
||||
assetDetail =
|
||||
await ref.watch(assetServiceProvider).getAssetById(asset.id);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
@@ -60,7 +62,9 @@ class VideoViewerPage extends HookConsumerWidget {
|
||||
showInfo();
|
||||
},
|
||||
onDownloadPressed: () {
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.downloadAsset(asset, context);
|
||||
},
|
||||
),
|
||||
body: SwipeDetector(
|
||||
@@ -93,7 +97,8 @@ class VideoThumbnailPlayer extends StatefulWidget {
|
||||
final String url;
|
||||
final String? jwtToken;
|
||||
|
||||
const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) : super(key: key);
|
||||
const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
|
||||
@@ -111,8 +116,8 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
||||
|
||||
Future<void> initializePlayer() async {
|
||||
try {
|
||||
videoPlayerController =
|
||||
VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"});
|
||||
videoPlayerController = VideoPlayerController.network(widget.url,
|
||||
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"});
|
||||
|
||||
await videoPlayerController.initialize();
|
||||
_createChewieController();
|
||||
@@ -142,7 +147,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return chewieController != null && chewieController!.videoPlayerController.value.isInitialized
|
||||
return chewieController?.videoPlayerController.value.isInitialized == true
|
||||
? SizedBox(
|
||||
child: Chewie(
|
||||
controller: chewieController!,
|
||||
|
||||
@@ -21,13 +21,16 @@ class AvailableAlbum {
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
|
||||
String toString() =>
|
||||
'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is AvailableAlbum && other.albumEntity == albumEntity && other.thumbnailData == thumbnailData;
|
||||
return other is AvailableAlbum &&
|
||||
other.albumEntity == albumEntity &&
|
||||
other.thumbnailData == thumbnailData;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||
|
||||
enum BackUpProgressEnum { idle, inProgress, done }
|
||||
|
||||
class BackUpState extends Equatable {
|
||||
class BackUpState {
|
||||
// enum
|
||||
final BackUpProgressEnum backupProgress;
|
||||
final List<String> allAssetOnDatabase;
|
||||
final List<String> allAssetsInDatabase;
|
||||
final double progressInPercentage;
|
||||
final CancelToken cancelToken;
|
||||
final CancellationToken cancelToken;
|
||||
final ServerInfo serverInfo;
|
||||
|
||||
/// All available albums on the device
|
||||
@@ -26,9 +27,12 @@ class BackUpState extends Equatable {
|
||||
/// All assets from the selected albums that have been backup
|
||||
final Set<String> selectedAlbumsBackupAssetsIds;
|
||||
|
||||
// Current Backup Asset
|
||||
final CurrentUploadAsset currentUploadAsset;
|
||||
|
||||
const BackUpState({
|
||||
required this.backupProgress,
|
||||
required this.allAssetOnDatabase,
|
||||
required this.allAssetsInDatabase,
|
||||
required this.progressInPercentage,
|
||||
required this.cancelToken,
|
||||
required this.serverInfo,
|
||||
@@ -37,23 +41,25 @@ class BackUpState extends Equatable {
|
||||
required this.excludedBackupAlbums,
|
||||
required this.allUniqueAssets,
|
||||
required this.selectedAlbumsBackupAssetsIds,
|
||||
required this.currentUploadAsset,
|
||||
});
|
||||
|
||||
BackUpState copyWith({
|
||||
BackUpProgressEnum? backupProgress,
|
||||
List<String>? allAssetOnDatabase,
|
||||
List<String>? allAssetsInDatabase,
|
||||
double? progressInPercentage,
|
||||
CancelToken? cancelToken,
|
||||
CancellationToken? cancelToken,
|
||||
ServerInfo? serverInfo,
|
||||
List<AvailableAlbum>? availableAlbums,
|
||||
Set<AssetPathEntity>? selectedBackupAlbums,
|
||||
Set<AssetPathEntity>? excludedBackupAlbums,
|
||||
Set<AssetEntity>? allUniqueAssets,
|
||||
Set<String>? selectedAlbumsBackupAssetsIds,
|
||||
CurrentUploadAsset? currentUploadAsset,
|
||||
}) {
|
||||
return BackUpState(
|
||||
backupProgress: backupProgress ?? this.backupProgress,
|
||||
allAssetOnDatabase: allAssetOnDatabase ?? this.allAssetOnDatabase,
|
||||
allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase,
|
||||
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
|
||||
cancelToken: cancelToken ?? this.cancelToken,
|
||||
serverInfo: serverInfo ?? this.serverInfo,
|
||||
@@ -61,28 +67,49 @@ class BackUpState extends Equatable {
|
||||
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
|
||||
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
|
||||
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
|
||||
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
|
||||
selectedAlbumsBackupAssetsIds:
|
||||
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
|
||||
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackUpState(backupProgress: $backupProgress, allAssetOnDatabase: $allAssetOnDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)';
|
||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props {
|
||||
return [
|
||||
backupProgress,
|
||||
allAssetOnDatabase,
|
||||
progressInPercentage,
|
||||
cancelToken,
|
||||
serverInfo,
|
||||
availableAlbums,
|
||||
selectedBackupAlbums,
|
||||
excludedBackupAlbums,
|
||||
allUniqueAssets,
|
||||
selectedAlbumsBackupAssetsIds,
|
||||
];
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
final collectionEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return other is BackUpState &&
|
||||
other.backupProgress == backupProgress &&
|
||||
collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
|
||||
other.progressInPercentage == progressInPercentage &&
|
||||
other.cancelToken == cancelToken &&
|
||||
other.serverInfo == serverInfo &&
|
||||
collectionEquals(other.availableAlbums, availableAlbums) &&
|
||||
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
|
||||
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
|
||||
collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
|
||||
collectionEquals(other.selectedAlbumsBackupAssetsIds,
|
||||
selectedAlbumsBackupAssetsIds) &&
|
||||
other.currentUploadAsset == currentUploadAsset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return backupProgress.hashCode ^
|
||||
allAssetsInDatabase.hashCode ^
|
||||
progressInPercentage.hashCode ^
|
||||
cancelToken.hashCode ^
|
||||
serverInfo.hashCode ^
|
||||
availableAlbums.hashCode ^
|
||||
selectedBackupAlbums.hashCode ^
|
||||
excludedBackupAlbums.hashCode ^
|
||||
allUniqueAssets.hashCode ^
|
||||
selectedAlbumsBackupAssetsIds.hashCode ^
|
||||
currentUploadAsset.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CheckDuplicateAssetResponse {
|
||||
final bool isExist;
|
||||
CheckDuplicateAssetResponse({
|
||||
required this.isExist,
|
||||
});
|
||||
|
||||
CheckDuplicateAssetResponse copyWith({
|
||||
bool? isExist,
|
||||
}) {
|
||||
return CheckDuplicateAssetResponse(
|
||||
isExist: isExist ?? this.isExist,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'isExist': isExist});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory CheckDuplicateAssetResponse.fromMap(Map<String, dynamic> map) {
|
||||
return CheckDuplicateAssetResponse(
|
||||
isExist: map['isExist'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CheckDuplicateAssetResponse.fromJson(String source) =>
|
||||
CheckDuplicateAssetResponse.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'CheckDuplicateAssetResponse(isExist: $isExist)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is CheckDuplicateAssetResponse && other.isExist == isExist;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isExist.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CurrentUploadAsset {
|
||||
final String id;
|
||||
final DateTime createdAt;
|
||||
final String fileName;
|
||||
final String fileType;
|
||||
|
||||
CurrentUploadAsset({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.fileName,
|
||||
required this.fileType,
|
||||
});
|
||||
|
||||
CurrentUploadAsset copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? fileName,
|
||||
String? fileType,
|
||||
}) {
|
||||
return CurrentUploadAsset(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
fileName: fileName ?? this.fileName,
|
||||
fileType: fileType ?? this.fileType,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'id': id});
|
||||
result.addAll({'createdAt': createdAt.millisecondsSinceEpoch});
|
||||
result.addAll({'fileName': fileName});
|
||||
result.addAll({'fileType': fileType});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory CurrentUploadAsset.fromMap(Map<String, dynamic> map) {
|
||||
return CurrentUploadAsset(
|
||||
id: map['id'] ?? '',
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt']),
|
||||
fileName: map['fileName'] ?? '',
|
||||
fileType: map['fileType'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CurrentUploadAsset.fromJson(String source) =>
|
||||
CurrentUploadAsset.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CurrentUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is CurrentUploadAsset &&
|
||||
other.id == id &&
|
||||
other.createdAt == createdAt &&
|
||||
other.fileName == fileName &&
|
||||
other.fileType == fileType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
fileName.hashCode ^
|
||||
fileType.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class ErrorUploadAsset extends Equatable {
|
||||
final String id;
|
||||
final DateTime createdAt;
|
||||
final String fileName;
|
||||
final String fileType;
|
||||
final AssetEntity asset;
|
||||
final String errorMessage;
|
||||
|
||||
const ErrorUploadAsset({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.fileName,
|
||||
required this.fileType,
|
||||
required this.asset,
|
||||
required this.errorMessage,
|
||||
});
|
||||
|
||||
ErrorUploadAsset copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? fileName,
|
||||
String? fileType,
|
||||
AssetEntity? asset,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return ErrorUploadAsset(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
fileName: fileName ?? this.fileName,
|
||||
fileType: fileType ?? this.fileType,
|
||||
asset: asset ?? this.asset,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ErrorUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType, asset: $asset, errorMessage: $errorMessage)';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props {
|
||||
return [
|
||||
id,
|
||||
fileName,
|
||||
fileType,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ class HiveBackupAlbums {
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)';
|
||||
String toString() =>
|
||||
'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)';
|
||||
|
||||
HiveBackupAlbums copyWith({
|
||||
List<String>? selectedAlbumIds,
|
||||
@@ -49,7 +50,8 @@ class HiveBackupAlbums {
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory HiveBackupAlbums.fromJson(String source) => HiveBackupAlbums.fromMap(json.decode(source));
|
||||
factory HiveBackupAlbums.fromJson(String source) =>
|
||||
HiveBackupAlbums.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info.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/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
BackupNotifier({this.ref})
|
||||
: super(
|
||||
BackupNotifier(
|
||||
this._backupService,
|
||||
this._serverInfoService,
|
||||
this._authState,
|
||||
this.ref,
|
||||
) : super(
|
||||
BackUpState(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
allAssetOnDatabase: const [],
|
||||
allAssetsInDatabase: const [],
|
||||
progressInPercentage: 0,
|
||||
cancelToken: CancelToken(),
|
||||
cancelToken: CancellationToken(),
|
||||
serverInfo: ServerInfo(
|
||||
diskAvailable: "0",
|
||||
diskAvailableRaw: 0,
|
||||
@@ -34,12 +42,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
excludedBackupAlbums: const {},
|
||||
allUniqueAssets: const {},
|
||||
selectedAlbumsBackupAssetsIds: const {},
|
||||
currentUploadAsset: CurrentUploadAsset(
|
||||
id: '...',
|
||||
createdAt: DateTime.parse('2020-10-04'),
|
||||
fileName: '...',
|
||||
fileType: '...',
|
||||
),
|
||||
),
|
||||
);
|
||||
) {
|
||||
getBackupInfo();
|
||||
}
|
||||
|
||||
Ref? ref;
|
||||
final BackupService _backupService = BackupService();
|
||||
final ServerInfoService _serverInfoService = ServerInfoService();
|
||||
final BackupService _backupService;
|
||||
final ServerInfoService _serverInfoService;
|
||||
final AuthenticationState _authState;
|
||||
final Ref ref;
|
||||
|
||||
///
|
||||
/// UI INTERACTION
|
||||
@@ -54,7 +71,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
removeExcludedAlbumForBackup(album);
|
||||
}
|
||||
|
||||
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
|
||||
state = state
|
||||
.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
|
||||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
@@ -62,7 +80,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
if (state.selectedBackupAlbums.contains(album)) {
|
||||
removeAlbumForBackup(album);
|
||||
}
|
||||
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
|
||||
state = state
|
||||
.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
|
||||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
@@ -90,19 +109,22 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// If this is the first time performing backup - set the default selected album to be
|
||||
/// the one that has all assets (Recent on Android, Recents on iOS)
|
||||
///
|
||||
Future<void> getBackupAlbumsInfo() async {
|
||||
Future<void> _getBackupAlbumsInfo() async {
|
||||
// Get all albums on the device
|
||||
List<AvailableAlbum> availableAlbums = [];
|
||||
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(hasAll: true, type: RequestType.common);
|
||||
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
|
||||
hasAll: true, type: RequestType.common);
|
||||
|
||||
for (AssetPathEntity album in albums) {
|
||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||
|
||||
var assetList = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assetList =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
|
||||
if (assetList.isNotEmpty) {
|
||||
var thumbnailAsset = assetList.first;
|
||||
var thumbnailData = await thumbnailAsset.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
var thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
}
|
||||
|
||||
@@ -113,7 +135,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
// Put persistent storage info into local state of the app
|
||||
// Get local storage on selected backup album
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
|
||||
backupInfoKey,
|
||||
defaultValue: HiveBackupAlbums(
|
||||
@@ -132,7 +155,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
debugPrint("First time backup setup recent album as default");
|
||||
|
||||
// Get album that contains all assets
|
||||
var list = await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
|
||||
var list = await PhotoManager.getAssetPathList(
|
||||
hasAll: true, onlyAll: true, type: RequestType.common);
|
||||
AssetPathEntity albumHasAllAssets = list.first;
|
||||
|
||||
backupAlbumInfoBox.put(
|
||||
@@ -150,12 +174,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
try {
|
||||
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
|
||||
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
|
||||
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset});
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset});
|
||||
}
|
||||
|
||||
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
|
||||
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
|
||||
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset});
|
||||
state = state.copyWith(
|
||||
excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] Failed to generate album from id $e");
|
||||
@@ -167,39 +193,45 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// Find the assets that are not overlapping between the two sets
|
||||
/// Those assets are unique and are used as the total assets
|
||||
///
|
||||
void _updateBackupAssetCount() async {
|
||||
Future<void> _updateBackupAssetCount() async {
|
||||
Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
for (var album in state.selectedBackupAlbums) {
|
||||
var assets = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
assetsFromSelectedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
for (var album in state.excludedBackupAlbums) {
|
||||
var assets = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
assetsFromExcludedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
Set<AssetEntity> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
||||
List<String> allAssetOnDatabase = await _backupService.getDeviceBackupAsset();
|
||||
Set<AssetEntity> allUniqueAssets =
|
||||
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
||||
List<String> allAssetsInDatabase =
|
||||
await _backupService.getDeviceBackupAsset();
|
||||
|
||||
// Find asset that were backup from selected albums
|
||||
Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id));
|
||||
selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId));
|
||||
Set<String> selectedAlbumsBackupAssets =
|
||||
Set.from(allUniqueAssets.map((e) => e.id));
|
||||
selectedAlbumsBackupAssets
|
||||
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
||||
|
||||
if (allUniqueAssets.isEmpty) {
|
||||
debugPrint("No Asset On Device");
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
allAssetOnDatabase: allAssetOnDatabase,
|
||||
allAssetsInDatabase: allAssetsInDatabase,
|
||||
allUniqueAssets: {},
|
||||
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
allAssetOnDatabase: allAssetOnDatabase,
|
||||
allAssetsInDatabase: allAssetsInDatabase,
|
||||
allUniqueAssets: allUniqueAssets,
|
||||
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||
);
|
||||
@@ -207,6 +239,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
// Save to persistent storage
|
||||
_updatePersistentAlbumsSelection();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
///
|
||||
@@ -214,10 +248,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// which albums are selected or excluded
|
||||
/// and then update the UI according to those information
|
||||
///
|
||||
void getBackupInfo() async {
|
||||
await getBackupAlbumsInfo();
|
||||
_updateServerInfo();
|
||||
_updateBackupAssetCount();
|
||||
Future<void> getBackupInfo() async {
|
||||
await Future.wait([
|
||||
_getBackupAlbumsInfo(),
|
||||
_updateServerInfo(),
|
||||
]);
|
||||
|
||||
await _updateBackupAssetCount();
|
||||
}
|
||||
|
||||
///
|
||||
@@ -225,7 +262,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// Hive database
|
||||
///
|
||||
void _updatePersistentAlbumsSelection() {
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
backupAlbumInfoBox.put(
|
||||
backupInfoKey,
|
||||
HiveBackupAlbums(
|
||||
@@ -239,11 +277,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// Invoke backup process
|
||||
///
|
||||
void startBackupProcess() async {
|
||||
_updateServerInfo();
|
||||
_updateBackupAssetCount();
|
||||
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||
|
||||
await getBackupInfo();
|
||||
|
||||
var authResult = await PhotoManager.requestPermissionExtend();
|
||||
if (authResult.isAuth) {
|
||||
await PhotoManager.clearFileCache();
|
||||
@@ -254,10 +291,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<AssetEntity> assetsWillBeBackup = state.allUniqueAssets;
|
||||
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
|
||||
|
||||
// Remove item that has already been backed up
|
||||
for (var assetId in state.allAssetOnDatabase) {
|
||||
for (var assetId in state.allAssetsInDatabase) {
|
||||
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
|
||||
}
|
||||
|
||||
@@ -266,35 +303,59 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
// Perform Backup
|
||||
state = state.copyWith(cancelToken: CancelToken());
|
||||
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress);
|
||||
state = state.copyWith(cancelToken: CancellationToken());
|
||||
_backupService.backupAsset(
|
||||
assetsWillBeBackup,
|
||||
state.cancelToken,
|
||||
_onAssetUploaded,
|
||||
_onUploadProgress,
|
||||
_onSetCurrentBackupAsset,
|
||||
_onBackupError,
|
||||
);
|
||||
} else {
|
||||
PhotoManager.openSetting();
|
||||
}
|
||||
}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
state = state.copyWith(currentUploadAsset: currentUploadAsset);
|
||||
}
|
||||
|
||||
void cancelBackup() {
|
||||
state.cancelToken.cancel('Cancel Backup');
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||
state.cancelToken.cancel();
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||
state = state.copyWith(
|
||||
selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, deviceAssetId},
|
||||
allAssetOnDatabase: [...state.allAssetOnDatabase, deviceAssetId]);
|
||||
state = state.copyWith(selectedAlbumsBackupAssetsIds: {
|
||||
...state.selectedAlbumsBackupAssetsIds,
|
||||
deviceAssetId
|
||||
}, allAssetsInDatabase: [
|
||||
...state.allAssetsInDatabase,
|
||||
deviceAssetId
|
||||
]);
|
||||
|
||||
if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) {
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
|
||||
if (state.allUniqueAssets.length -
|
||||
state.selectedAlbumsBackupAssetsIds.length ==
|
||||
0) {
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
|
||||
}
|
||||
|
||||
_updateServerInfo();
|
||||
}
|
||||
|
||||
void _onUploadProgress(int sent, int total) {
|
||||
state = state.copyWith(progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
|
||||
state = state.copyWith(
|
||||
progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
|
||||
}
|
||||
|
||||
void _updateServerInfo() async {
|
||||
Future<void> _updateServerInfo() async {
|
||||
var serverInfo = await _serverInfoService.getServerInfo();
|
||||
|
||||
// Update server info
|
||||
@@ -312,36 +373,39 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
void resumeBackup() {
|
||||
var authState = ref?.read(authenticationProvider);
|
||||
|
||||
// Check if user is login
|
||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
|
||||
// User has been logged out return
|
||||
if (authState != null) {
|
||||
if (accessKey == null || !authState.isAuthenticated) {
|
||||
debugPrint("[resumeBackup] not authenticated - abort");
|
||||
if (accessKey == null || !_authState.isAuthenticated) {
|
||||
debugPrint("[resumeBackup] not authenticated - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this device is enable backup by the user
|
||||
if ((_authState.deviceInfo.deviceId == _authState.deviceId) &&
|
||||
_authState.deviceInfo.isAutoBackup) {
|
||||
// check if backup is alreayd in process - then return
|
||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this device is enable backup by the user
|
||||
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
||||
// check if backup is alreayd in process - then return
|
||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run backup
|
||||
debugPrint("[resumeBackup] Start back up");
|
||||
startBackupProcess();
|
||||
}
|
||||
|
||||
return;
|
||||
// Run backup
|
||||
debugPrint("[resumeBackup] Start back up");
|
||||
startBackupProcess();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
return BackupNotifier(ref: ref);
|
||||
final backupProvider =
|
||||
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
return BackupNotifier(
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(serverInfoServiceProvider),
|
||||
ref.watch(authenticationProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
|
||||
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
|
||||
ErrorBackupListNotifier() : super({});
|
||||
|
||||
add(ErrorUploadAsset errorAsset) {
|
||||
state = state.union({errorAsset});
|
||||
}
|
||||
|
||||
remove(ErrorUploadAsset errorAsset) {
|
||||
state = state.difference({errorAsset});
|
||||
}
|
||||
|
||||
empty() {
|
||||
state = {};
|
||||
}
|
||||
}
|
||||
|
||||
final errorBackupListProvider =
|
||||
StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
|
||||
(ref) => ErrorBackupListNotifier(),
|
||||
);
|
||||
@@ -5,39 +5,71 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/check_duplicate_asset_response.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/shared/services/network.service.dart';
|
||||
import 'package:immich_mobile/shared/models/device_info.model.dart';
|
||||
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
|
||||
import 'package:immich_mobile/utils/files_helper.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:cancellation_token_http/http.dart' as http;
|
||||
|
||||
final backupServiceProvider =
|
||||
Provider((ref) => BackupService(ref.watch(networkServiceProvider)));
|
||||
|
||||
class BackupService {
|
||||
final NetworkService _networkService = NetworkService();
|
||||
final NetworkService _networkService;
|
||||
|
||||
BackupService(this._networkService);
|
||||
|
||||
Future<List<String>> getDeviceBackupAsset() async {
|
||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
|
||||
Response response = await _networkService.getRequest(url: "asset/$deviceId");
|
||||
Response response =
|
||||
await _networkService.getRequest(url: "asset/$deviceId");
|
||||
List<dynamic> result = jsonDecode(response.toString());
|
||||
|
||||
return result.cast<String>();
|
||||
}
|
||||
|
||||
backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
|
||||
Function(int, int) uploadProgress) async {
|
||||
var dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
Future<bool> checkDuplicateAsset(String deviceAssetId) async {
|
||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
|
||||
try {
|
||||
Response response =
|
||||
await _networkService.postRequest(url: "asset/check", data: {
|
||||
"deviceId": deviceId,
|
||||
"deviceAssetId": deviceAssetId,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
var result = CheckDuplicateAssetResponse.fromJson(response.toString());
|
||||
|
||||
return result.isExist;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
backupAsset(
|
||||
Set<AssetEntity> assetList,
|
||||
http.CancellationToken cancelToken,
|
||||
Function(String, String) singleAssetDoneCb,
|
||||
Function(int, int) uploadProgressCb,
|
||||
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
||||
Function(ErrorUploadAsset) errorCb,
|
||||
) async {
|
||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
File? file;
|
||||
|
||||
MultipartFile assetRawUploadData;
|
||||
MultipartFile thumbnailUploadData;
|
||||
|
||||
for (var entity in assetList) {
|
||||
try {
|
||||
if (entity.type == AssetType.video) {
|
||||
@@ -47,75 +79,76 @@ class BackupService {
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
FormData formData;
|
||||
String originalFileName = await entity.titleAsync;
|
||||
String fileNameWithoutPath = originalFileName.toString().split(".")[0];
|
||||
String fileNameWithoutPath =
|
||||
originalFileName.toString().split(".")[0];
|
||||
var fileExtension = p.extension(file.path);
|
||||
var mimeType = FileHelper.getMimeType(file.path);
|
||||
assetRawUploadData = await MultipartFile.fromFile(
|
||||
file.path,
|
||||
var fileStream = file.openRead();
|
||||
var assetRawUploadData = http.MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
file.lengthSync(),
|
||||
filename: fileNameWithoutPath,
|
||||
contentType: MediaType(
|
||||
mimeType["type"],
|
||||
mimeType["subType"],
|
||||
),
|
||||
);
|
||||
formData = FormData.fromMap({
|
||||
'deviceAssetId': entity.id,
|
||||
'deviceId': deviceId,
|
||||
'assetType': _getAssetType(entity.type),
|
||||
'createdAt': entity.createDateTime.toIso8601String(),
|
||||
'modifiedAt': entity.modifiedDateTime.toIso8601String(),
|
||||
'isFavorite': entity.isFavorite,
|
||||
'fileExtension': fileExtension,
|
||||
'duration': entity.videoDuration,
|
||||
'assetData': [assetRawUploadData]
|
||||
});
|
||||
|
||||
// Build thumbnail multipart data
|
||||
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
|
||||
if (thumbnailData != null) {
|
||||
thumbnailUploadData = MultipartFile.fromBytes(
|
||||
List.from(thumbnailData),
|
||||
filename: fileNameWithoutPath,
|
||||
contentType: MediaType(
|
||||
"image",
|
||||
"jpeg",
|
||||
),
|
||||
);
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
// Send thumbnail data if it is exist
|
||||
formData = FormData.fromMap({
|
||||
'deviceAssetId': entity.id,
|
||||
'deviceId': deviceId,
|
||||
'assetType': _getAssetType(entity.type),
|
||||
'createdAt': entity.createDateTime.toIso8601String(),
|
||||
'modifiedAt': entity.modifiedDateTime.toIso8601String(),
|
||||
'isFavorite': entity.isFavorite,
|
||||
'fileExtension': fileExtension,
|
||||
'duration': entity.videoDuration,
|
||||
'thumbnailData': [thumbnailUploadData],
|
||||
'assetData': [assetRawUploadData]
|
||||
});
|
||||
}
|
||||
var req = MultipartRequest(
|
||||
'POST', Uri.parse('$savedEndpoint/asset/upload'),
|
||||
onProgress: ((bytes, totalBytes) =>
|
||||
uploadProgressCb(bytes, totalBytes)));
|
||||
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
|
||||
|
||||
Response res = await dio.post(
|
||||
'$savedEndpoint/asset/upload',
|
||||
data: formData,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: (sent, total) => uploadProgress(sent, total),
|
||||
req.fields['deviceAssetId'] = entity.id;
|
||||
req.fields['deviceId'] = deviceId;
|
||||
req.fields['assetType'] = _getAssetType(entity.type);
|
||||
req.fields['createdAt'] = entity.createDateTime.toIso8601String();
|
||||
req.fields['modifiedAt'] = entity.modifiedDateTime.toIso8601String();
|
||||
req.fields['isFavorite'] = entity.isFavorite.toString();
|
||||
req.fields['fileExtension'] = fileExtension;
|
||||
req.fields['duration'] = entity.videoDuration.toString();
|
||||
|
||||
req.files.add(assetRawUploadData);
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
createdAt: entity.createDateTime,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(entity.type),
|
||||
),
|
||||
);
|
||||
|
||||
if (res.statusCode == 201) {
|
||||
var response = await req.send(cancellationToken: cancelToken);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
singleAssetDoneCb(entity.id, deviceId);
|
||||
} else {
|
||||
var data = await response.stream.bytesToString();
|
||||
var error = jsonDecode(data);
|
||||
|
||||
debugPrint(
|
||||
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}");
|
||||
|
||||
errorCb(ErrorUploadAsset(
|
||||
asset: entity,
|
||||
id: entity.id,
|
||||
createdAt: entity.createDateTime,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(entity.type),
|
||||
errorMessage: error['error'],
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError backupAsset: ${e.response}");
|
||||
if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
} on http.CancelledException {
|
||||
debugPrint("Backup was cancelled by the user");
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||
continue;
|
||||
@@ -127,6 +160,8 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
void sendBackupRequest(AssetEntity entity) {}
|
||||
|
||||
String _getAssetType(AssetType assetType) {
|
||||
switch (assetType) {
|
||||
case AssetType.audio:
|
||||
@@ -140,7 +175,8 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<DeviceInfoRemote> setAutoBackup(bool status, String deviceId, String deviceType) async {
|
||||
Future<DeviceInfoRemote> setAutoBackup(
|
||||
bool status, String deviceId, String deviceType) async {
|
||||
var res = await _networkService.patchRequest(url: 'device-info', data: {
|
||||
"isAutoBackup": status,
|
||||
"deviceId": deviceId,
|
||||
@@ -150,3 +186,35 @@ class BackupService {
|
||||
return DeviceInfoRemote.fromJson(res.toString());
|
||||
}
|
||||
}
|
||||
|
||||
class MultipartRequest extends http.MultipartRequest {
|
||||
/// Creates a new [MultipartRequest].
|
||||
MultipartRequest(
|
||||
String method,
|
||||
Uri url, {
|
||||
required this.onProgress,
|
||||
}) : super(method, url);
|
||||
|
||||
final void Function(int bytes, int totalBytes) onProgress;
|
||||
|
||||
/// Freezes all mutable fields and returns a
|
||||
/// single-subscription [http.ByteStream]
|
||||
/// that will emit the request body.
|
||||
@override
|
||||
http.ByteStream finalize() {
|
||||
final byteStream = super.finalize();
|
||||
|
||||
final total = contentLength;
|
||||
var bytes = 0;
|
||||
|
||||
final t = StreamTransformer.fromHandlers(
|
||||
handleData: (List<int> data, EventSink<List<int>> sink) {
|
||||
bytes += data.length;
|
||||
onProgress.call(bytes, total);
|
||||
sink.add(data);
|
||||
},
|
||||
);
|
||||
final stream = byteStream.transform(t);
|
||||
return http.ByteStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,22 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
final Uint8List? imageData;
|
||||
final AssetPathEntity albumInfo;
|
||||
|
||||
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo}) : super(key: key);
|
||||
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
||||
final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
||||
final bool isSelected =
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
||||
final bool isExcluded =
|
||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
||||
|
||||
ColorFilter selectedFilter = ColorFilter.mode(Theme.of(context).primaryColor.withAlpha(100), BlendMode.darken);
|
||||
ColorFilter excludedFilter = ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
|
||||
ColorFilter unselectedFilter = const ColorFilter.mode(Colors.black, BlendMode.color);
|
||||
ColorFilter selectedFilter = ColorFilter.mode(
|
||||
Theme.of(context).primaryColor.withAlpha(100), BlendMode.darken);
|
||||
ColorFilter excludedFilter =
|
||||
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
|
||||
ColorFilter unselectedFilter =
|
||||
const ColorFilter.mode(Colors.black, BlendMode.color);
|
||||
|
||||
_buildSelectedTextBox() {
|
||||
if (isSelected) {
|
||||
@@ -32,7 +38,8 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
label: const Text(
|
||||
"INCLUDED",
|
||||
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
);
|
||||
@@ -42,13 +49,14 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
label: const Text(
|
||||
"EXCLUDED",
|
||||
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: Colors.red[300],
|
||||
);
|
||||
}
|
||||
|
||||
return Container();
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
_buildImageFilter() {
|
||||
@@ -85,10 +93,15 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (isExcluded) {
|
||||
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(albumInfo);
|
||||
ref
|
||||
.watch(backupProvider.notifier)
|
||||
.removeExcludedAlbumForBackup(albumInfo);
|
||||
} else {
|
||||
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 &&
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo)) {
|
||||
ref
|
||||
.watch(backupProvider)
|
||||
.selectedBackupAlbums
|
||||
.contains(albumInfo)) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Cannot exclude the only album",
|
||||
@@ -98,7 +111,9 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.watch(backupProvider.notifier).addExcludedAlbumForBackup(albumInfo);
|
||||
ref
|
||||
.watch(backupProvider.notifier)
|
||||
.addExcludedAlbumForBackup(albumInfo);
|
||||
}
|
||||
},
|
||||
child: Card(
|
||||
@@ -121,18 +136,26 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12)),
|
||||
image: DecorationImage(
|
||||
colorFilter: _buildImageFilter(),
|
||||
image: imageData != null
|
||||
? MemoryImage(imageData!)
|
||||
: const AssetImage('assets/immich-logo-no-outline.png') as ImageProvider,
|
||||
: const AssetImage(
|
||||
'assets/immich-logo-no-outline.png')
|
||||
as ImageProvider,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: null,
|
||||
),
|
||||
Positioned(bottom: 10, left: 25, child: _buildSelectedTextBox())
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 25,
|
||||
child: _buildSelectedTextBox(),
|
||||
)
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
@@ -150,13 +173,16 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
Text(
|
||||
albumInfo.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Text(
|
||||
albumInfo.assetCount.toString() + (albumInfo.isAll ? " (ALL)" : ""),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
'${albumInfo.assetCount} ${(albumInfo.isAll ? " (ALL)" : "")}',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -165,7 +191,8 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(AlbumPreviewRoute(album: albumInfo));
|
||||
AutoRouter.of(context)
|
||||
.push(AlbumPreviewRoute(album: albumInfo));
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
|
||||
@@ -4,7 +4,12 @@ class BackupInfoCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String info;
|
||||
const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key);
|
||||
const BackupInfoCard(
|
||||
{Key? key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.info})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -16,7 +16,8 @@ class AlbumPreviewPage extends HookConsumerWidget {
|
||||
final assets = useState<List<AssetEntity>>([]);
|
||||
|
||||
_getAssetsInAlbum() async {
|
||||
assets.value = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
assets.value =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
@@ -37,7 +38,10 @@ class AlbumPreviewPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
"ID ${album.id}",
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600], fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -55,8 +59,9 @@ class AlbumPreviewPage extends HookConsumerWidget {
|
||||
),
|
||||
itemCount: assets.value.length,
|
||||
itemBuilder: (context, index) {
|
||||
Future<Uint8List?> thumbData =
|
||||
assets.value[index].thumbnailDataWithSize(const ThumbnailSize(200, 200), quality: 50);
|
||||
Future<Uint8List?> thumbData = assets.value[index]
|
||||
.thumbnailDataWithSize(const ThumbnailSize(200, 200),
|
||||
quality: 50);
|
||||
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: thumbData,
|
||||
|
||||
@@ -17,7 +17,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||
|
||||
useEffect(() {
|
||||
ref.read(backupProvider.notifier).getBackupAlbumsInfo();
|
||||
ref.read(backupProvider.notifier).getBackupInfo();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
@@ -37,8 +37,12 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
itemBuilder: ((context, index) {
|
||||
var thumbnailData = availableAlbums[index].thumbnailData;
|
||||
return Padding(
|
||||
padding: index == 0 ? const EdgeInsets.only(left: 16.00) : const EdgeInsets.all(0),
|
||||
child: AlbumInfoCard(imageData: thumbnailData, albumInfo: availableAlbums[index].albumEntity),
|
||||
padding: index == 0
|
||||
? const EdgeInsets.only(left: 16.00)
|
||||
: const EdgeInsets.all(0),
|
||||
child: AlbumInfoCard(
|
||||
imageData: thumbnailData,
|
||||
albumInfo: availableAlbums[index].albumEntity),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -67,10 +71,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
onTap: removeSelection,
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5)),
|
||||
label: Text(
|
||||
album.name,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
deleteIconColor: Colors.white,
|
||||
@@ -88,7 +96,9 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
_buildExcludedAlbumNameChip() {
|
||||
return excludedBackupAlbums.map((album) {
|
||||
void removeSelection() {
|
||||
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album);
|
||||
ref
|
||||
.watch(backupProvider.notifier)
|
||||
.removeExcludedAlbumForBackup(album);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
@@ -97,10 +107,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5)),
|
||||
label: Text(
|
||||
album.name,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: Colors.red[300],
|
||||
deleteIconColor: Colors.white,
|
||||
@@ -142,7 +156,10 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Wrap(
|
||||
children: [..._buildSelectedAlbumNameChip(), ..._buildExcludedAlbumNameChip()],
|
||||
children: [
|
||||
..._buildSelectedAlbumNameChip(),
|
||||
..._buildExcludedAlbumNameChip()
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -165,10 +182,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Text(
|
||||
"Total unique assets",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.grey[700]),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700]),
|
||||
),
|
||||
trailing: Text(
|
||||
ref.watch(backupProvider).allUniqueAssets.length.toString(),
|
||||
'${ref.watch(backupProvider).allUniqueAssets.length}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@@ -179,7 +199,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
|
||||
ListTile(
|
||||
title: Text(
|
||||
"Albums on device (${availableAlbums.length.toString()})",
|
||||
"Albums on device (${availableAlbums.length})",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
subtitle: Padding(
|
||||
@@ -206,7 +226,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'Selection Info',
|
||||
@@ -221,7 +242,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
|
||||
style: TextStyle(
|
||||
fontSize: 14, color: Colors.grey[700]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||
|
||||
class BackupControllerPage extends HookConsumerWidget {
|
||||
@@ -17,16 +19,21 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
BackUpState backupState = ref.watch(backupProvider);
|
||||
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
|
||||
bool shouldBackup =
|
||||
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
|
||||
AuthenticationState authenticationState = ref.watch(authenticationProvider);
|
||||
bool shouldBackup = backupState.allUniqueAssets.length -
|
||||
backupState.selectedAlbumsBackupAssetsIds.length ==
|
||||
0
|
||||
? false
|
||||
: true;
|
||||
|
||||
useEffect(() {
|
||||
if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
||||
ref.read(backupProvider.notifier).getBackupInfo();
|
||||
ref.watch(backupProvider.notifier).getBackupInfo();
|
||||
}
|
||||
|
||||
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success');
|
||||
ref
|
||||
.watch(websocketProvider.notifier)
|
||||
.stopListenToEvent('on_upload_success');
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
@@ -37,7 +44,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
title: const Text(
|
||||
"Server Storage",
|
||||
"Server storage",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
subtitle: Padding(
|
||||
@@ -48,9 +55,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: LinearPercentIndicator(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
||||
barRadius: const Radius.circular(2),
|
||||
lineHeight: 6.0,
|
||||
lineHeight: 10.0,
|
||||
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
|
||||
backgroundColor: Colors.grey,
|
||||
progressColor: Theme.of(context).primaryColor,
|
||||
@@ -58,7 +66,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Text('${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'),
|
||||
child: Text(
|
||||
'${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -67,9 +76,11 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
ListTile _buildBackupController() {
|
||||
var backUpOption = _authenticationState.deviceInfo.isAutoBackup ? "on" : "off";
|
||||
var isAutoBackup = _authenticationState.deviceInfo.isAutoBackup;
|
||||
var backupBtnText = _authenticationState.deviceInfo.isAutoBackup ? "off" : "on";
|
||||
var backUpOption =
|
||||
authenticationState.deviceInfo.isAutoBackup ? "on" : "off";
|
||||
var isAutoBackup = authenticationState.deviceInfo.isAutoBackup;
|
||||
var backupBtnText =
|
||||
authenticationState.deviceInfo.isAutoBackup ? "off" : "on";
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
leading: isAutoBackup
|
||||
@@ -87,21 +98,33 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
!isAutoBackup
|
||||
? const Text(
|
||||
"Turn on backup to automatically upload new assets to the server.",
|
||||
style: TextStyle(fontSize: 14),
|
||||
)
|
||||
: Container(),
|
||||
if (!isAutoBackup)
|
||||
const Text(
|
||||
"Turn on backup to automatically upload new assets to the server.",
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(
|
||||
width: 1,
|
||||
color: Color.fromARGB(255, 220, 220, 220),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
isAutoBackup
|
||||
? ref.watch(authenticationProvider.notifier).setAutoBackup(false)
|
||||
: ref.watch(authenticationProvider.notifier).setAutoBackup(true);
|
||||
if (isAutoBackup) {
|
||||
ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.setAutoBackup(false);
|
||||
} else {
|
||||
ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.setAutoBackup(true);
|
||||
}
|
||||
},
|
||||
child: Text("Turn $backupBtnText Backup", style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
child: Text("Turn $backupBtnText Backup",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -127,7 +150,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
text.trim().substring(0, text.length - 2),
|
||||
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -135,7 +161,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
"None selected",
|
||||
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -154,11 +183,14 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
text.trim().substring(0, text.length - 2),
|
||||
style: TextStyle(color: Colors.red[300], fontSize: 12, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Colors.red[300],
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,14 +207,15 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
borderOnForeground: false,
|
||||
child: ListTile(
|
||||
minVerticalPadding: 15,
|
||||
title: const Text("Backup Albums", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
|
||||
title: const Text("Backup Albums",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Albums to be backup",
|
||||
"Albums to be backed up",
|
||||
style: TextStyle(color: Color(0xFF808080), fontSize: 12),
|
||||
),
|
||||
_buildSelectedAlbumName(),
|
||||
@@ -191,6 +224,13 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
trailing: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
enableFeedback: true,
|
||||
side: const BorderSide(
|
||||
width: 1,
|
||||
color: Color.fromARGB(255, 220, 220, 220),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
|
||||
},
|
||||
@@ -208,6 +248,141 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_buildCurrentBackupAssetInfoCard() {
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Uploading file info",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
if (ref.watch(errorBackupListProvider).isNotEmpty)
|
||||
ActionChip(
|
||||
avatar: Icon(
|
||||
Icons.info,
|
||||
size: 24,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
elevation: 1,
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(
|
||||
"Failed (${ref.watch(errorBackupListProvider).length})",
|
||||
style: TextStyle(
|
||||
color: Colors.red[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(const FailedBackupStatusRoute());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: LinearPercentIndicator(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
||||
barRadius: const Radius.circular(2),
|
||||
lineHeight: 10.0,
|
||||
trailing: Text(
|
||||
" ${backupState.progressInPercentage.toStringAsFixed(0)}%",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
percent: backupState.progressInPercentage / 100.0,
|
||||
backgroundColor: Colors.grey,
|
||||
progressColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Table(
|
||||
border: TableBorder.all(
|
||||
color: Colors.black12,
|
||||
width: 1,
|
||||
),
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text(
|
||||
'File name: ${backupState.currentUploadAsset.fileName} [${backupState.currentUploadAsset.fileType.toLowerCase()}]',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 10.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text(
|
||||
"Created on: ${DateFormat.yMMMMd('en_US').format(
|
||||
DateTime.parse(
|
||||
backupState.currentUploadAsset.createdAt
|
||||
.toString(),
|
||||
),
|
||||
)}",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 10.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text(
|
||||
"ID: ${backupState.currentUploadAsset.id}",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void startBackup() {
|
||||
ref.watch(errorBackupListProvider.notifier).empty();
|
||||
ref.watch(backupProvider.notifier).startBackupProcess();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
@@ -226,7 +401,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
)),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
|
||||
child: ListView(
|
||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -245,53 +420,60 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
BackupInfoCard(
|
||||
title: "Backup",
|
||||
subtitle: "Photos and videos from selected albums that are backup",
|
||||
subtitle: "Backed up photos and videos",
|
||||
info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||
),
|
||||
BackupInfoCard(
|
||||
title: "Remainder",
|
||||
subtitle: "Photos and videos that has not been backing up from selected albums",
|
||||
info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||
subtitle: "Remaining photos and albums to back up from selection",
|
||||
info:
|
||||
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||
),
|
||||
const Divider(),
|
||||
_buildBackupController(),
|
||||
const Divider(),
|
||||
_buildStorageInformation(),
|
||||
const Divider(),
|
||||
_buildCurrentBackupAssetInfoCard(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
"Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Row(children: [
|
||||
const Text("Backup Progress:"),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? const CircularProgressIndicator.adaptive()
|
||||
: const Text("Done"),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.only(
|
||||
top: 24,
|
||||
),
|
||||
child: Container(
|
||||
child: backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(primary: Colors.red[300]),
|
||||
onPressed: () {
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
},
|
||||
child: const Text("Cancel"),
|
||||
)
|
||||
: ElevatedButton(
|
||||
onPressed: shouldBackup
|
||||
? () {
|
||||
ref.read(backupProvider.notifier).startBackupProcess();
|
||||
}
|
||||
: null,
|
||||
child: const Text("Start Backup"),
|
||||
),
|
||||
child:
|
||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.red[300],
|
||||
onPrimary: Colors.grey[50],
|
||||
padding: const EdgeInsets.all(14),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
},
|
||||
child: const Text(
|
||||
"CANCEL",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
onPrimary: Colors.grey[50],
|
||||
padding: const EdgeInsets.all(14),
|
||||
),
|
||||
onPressed: shouldBackup ? startBackup : null,
|
||||
child: const Text(
|
||||
"START BACKUP",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
|
||||
139
mobile/lib/modules/backup/views/failed_backup_status_page.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class FailedBackupStatusPage extends HookConsumerWidget {
|
||||
const FailedBackupStatusPage({Key? key}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final errorBackupList = ref.watch(errorBackupListProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
"Failed Backup (${errorBackupList.length})",
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop(true);
|
||||
},
|
||||
splashRadius: 24,
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_rounded,
|
||||
)),
|
||||
),
|
||||
body: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: errorBackupList.length,
|
||||
itemBuilder: ((context, index) {
|
||||
var errorAsset = errorBackupList.elementAt(index);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12.0,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15), // if you need this
|
||||
side: const BorderSide(
|
||||
color: Colors.black12,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
elevation: 0,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 100,
|
||||
minHeight: 150,
|
||||
maxWidth: 100,
|
||||
maxHeight: 200,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(15),
|
||||
topLeft: Radius.circular(15),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
image: AssetEntityImageProvider(
|
||||
errorAsset.asset,
|
||||
isOriginal: false,
|
||||
thumbnailSize: const ThumbnailSize.square(512),
|
||||
thumbnailFormat: ThumbnailFormat.jpeg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
DateFormat.yMMMMd('en_US').format(
|
||||
DateTime.parse(
|
||||
errorAsset.createdAt.toString(),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[700]),
|
||||
),
|
||||
Icon(
|
||||
Icons.error,
|
||||
color: Colors.red.withAlpha(200),
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
errorAsset.fileName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
errorAsset.errorMessage,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||