mirror of
https://github.com/immich-app/immich.git
synced 2026-02-05 01:09:14 +03:00
Compare commits
29 Commits
v1.11.0_17
...
v1.13.0_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
20
.github/workflows/build_push_server_release.yml
vendored
20
.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
|
||||
@@ -44,43 +44,37 @@ jobs:
|
||||
tags: |
|
||||
altran1502/immich-server:${{ steps.previoustag.outputs.tag }}
|
||||
|
||||
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 }}
|
||||
|
||||
build_and_push_web_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
17
.github/workflows/test.yml
vendored
Normal file
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
|
||||
4
Makefile
4
Makefile
@@ -5,7 +5,7 @@ dev-update:
|
||||
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
|
||||
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
|
||||
@@ -17,4 +17,4 @@ 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=5 --scale immich-microservices=3 --remove-orphans
|
||||
65
README.md
65
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/rxnyVTXGbM">
|
||||
<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">
|
||||
@@ -257,5 +256,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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 154 KiB |
BIN
design/feature-panel.png
Normal file
BIN
design/feature-panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
design/web-admin.jpeg
Normal file
BIN
design/web-admin.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
design/web-detail.jpeg
Normal file
BIN
design/web-detail.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
BIN
design/web-home.jpeg
Normal file
BIN
design/web-home.jpeg
Normal file
Binary file not shown.
|
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=
|
||||
|
||||
@@ -19,4 +19,4 @@ ENABLE_MAPBOX=false
|
||||
|
||||
# WEB
|
||||
MAPBOX_KEY=
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
@@ -2,7 +2,7 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
image: immich-server-dev:1.9.0
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
@@ -20,11 +20,9 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
immich-machine-learning:
|
||||
image: immich-machine-learning-dev:1.9.0
|
||||
image: immich-machine-learning-dev:latest
|
||||
build:
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
@@ -41,11 +39,9 @@ services:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
immich-microservices:
|
||||
image: immich-microservices:1.9.0
|
||||
image: immich-microservices:latest
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
@@ -60,8 +56,7 @@ services:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
- immich-server
|
||||
|
||||
immich-web:
|
||||
image: immich-web-dev:1.9.0
|
||||
@@ -73,20 +68,16 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 3002:3002
|
||||
- 3002: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
|
||||
@@ -102,8 +93,6 @@ services:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
nginx:
|
||||
container_name: proxy_nginx
|
||||
@@ -115,12 +104,8 @@ services:
|
||||
- 2284:443
|
||||
logging:
|
||||
driver: none
|
||||
networks:
|
||||
- immich-network
|
||||
depends_on:
|
||||
- immich-server
|
||||
|
||||
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:
|
||||
@@ -4,8 +4,6 @@ services:
|
||||
immich-server:
|
||||
image: altran1502/immich-server:staging
|
||||
entrypoint: ["/bin/sh", "./start-server.sh"]
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -15,8 +13,6 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
@@ -31,15 +27,11 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
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:
|
||||
@@ -48,8 +40,6 @@ services:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-web:
|
||||
@@ -57,17 +47,11 @@ services:
|
||||
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:
|
||||
@@ -82,10 +66,6 @@ services:
|
||||
PG_DATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
@@ -98,13 +78,9 @@ services:
|
||||
- 2284:443
|
||||
logging:
|
||||
driver: none
|
||||
networks:
|
||||
- immich-network
|
||||
depends_on:
|
||||
- immich-server
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
immich-network:
|
||||
volumes:
|
||||
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
|
||||
@@ -43,8 +38,3 @@ services:
|
||||
- /var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich_network_test
|
||||
|
||||
networks:
|
||||
immich_network_test:
|
||||
|
||||
@@ -4,8 +4,6 @@ services:
|
||||
immich-server:
|
||||
image: altran1502/immich-server:latest
|
||||
entrypoint: ["/bin/sh", "./start-server.sh"]
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -15,8 +13,6 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
@@ -31,15 +27,11 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-machine-learning:
|
||||
image: altran1502/immich-machine-learning:latest
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -48,8 +40,6 @@ services:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
immich-web:
|
||||
@@ -57,17 +47,11 @@ services:
|
||||
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:
|
||||
@@ -82,10 +66,6 @@ services:
|
||||
PG_DATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
networks:
|
||||
- immich-network
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
@@ -98,13 +78,9 @@ services:
|
||||
- 2284:443
|
||||
logging:
|
||||
driver: none
|
||||
networks:
|
||||
- immich-network
|
||||
depends_on:
|
||||
- immich-server
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
immich-network:
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -19,6 +19,33 @@ server {
|
||||
listen 80;
|
||||
access_log off;
|
||||
|
||||
location /api {
|
||||
|
||||
# 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;
|
||||
|
||||
rewrite /api/(.*) /$1 break;
|
||||
|
||||
proxy_pass http://immich-server:3000;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
# Compression
|
||||
@@ -41,6 +68,6 @@ server {
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_pass http://immich-server:3000;
|
||||
proxy_pass http://immich-web:3000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
* 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.11.0"
|
||||
version_number: "1.13.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -2,18 +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";
|
||||
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey";
|
||||
const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; // Box
|
||||
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
|
||||
|
||||
@@ -4,18 +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/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.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 {
|
||||
@@ -39,13 +40,14 @@ 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) {
|
||||
@@ -121,7 +123,8 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
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,
|
||||
@@ -132,7 +135,8 @@ 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(),
|
||||
|
||||
@@ -9,12 +9,14 @@ 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)
|
||||
return (assetDetail.exifInfo!.latitude != null &&
|
||||
assetDetail.exifInfo!.longitude != null)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Container(
|
||||
@@ -25,12 +27,14 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
center: LatLng(assetDetail.exifInfo!.latitude!, assetDetail.exifInfo!.longitude!),
|
||||
center: LatLng(assetDetail.exifInfo!.latitude!,
|
||||
assetDetail.exifInfo!.longitude!),
|
||||
zoom: 16.0,
|
||||
),
|
||||
layers: [
|
||||
TileLayerOptions(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
urlTemplate:
|
||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
attributionBuilder: (_) {
|
||||
return const Text(
|
||||
@@ -43,8 +47,10 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
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')),
|
||||
point: LatLng(assetDetail.exifInfo!.latitude!,
|
||||
assetDetail.exifInfo!.longitude!),
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -56,10 +62,14 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
}
|
||||
|
||||
_buildLocationText() {
|
||||
return (assetDetail.exifInfo!.city != null && assetDetail.exifInfo!.state != null)
|
||||
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),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[200],
|
||||
fontWeight: FontWeight.bold),
|
||||
)
|
||||
: Container();
|
||||
}
|
||||
@@ -131,7 +141,8 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"DETAILS",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
@@ -158,7 +169,8 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
leading: const Icon(Icons.camera),
|
||||
title: Text(
|
||||
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
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} "),
|
||||
|
||||
113
mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
Normal file
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';
|
||||
@@ -63,64 +62,19 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||
},
|
||||
),
|
||||
body: SwipeDetector(
|
||||
onSwipeDown: (_) {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
onSwipeUp: (_) {
|
||||
showInfo();
|
||||
},
|
||||
child: SafeArea(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
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],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: RemotePhotoView(
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
imageUrl: imageUrl,
|
||||
authToken: "Bearer ${box.get(accessTokenKey)}",
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: () => showInfo(),
|
||||
)
|
||||
),
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
@@ -130,7 +84,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
@@ -12,7 +12,7 @@ class BackUpState extends Equatable {
|
||||
final BackUpProgressEnum backupProgress;
|
||||
final List<String> allAssetOnDatabase;
|
||||
final double progressInPercentage;
|
||||
final CancelToken cancelToken;
|
||||
final CancellationToken cancelToken;
|
||||
final ServerInfo serverInfo;
|
||||
|
||||
/// All available albums on the device
|
||||
@@ -43,7 +43,7 @@ class BackUpState extends Equatable {
|
||||
BackUpProgressEnum? backupProgress,
|
||||
List<String>? allAssetOnDatabase,
|
||||
double? progressInPercentage,
|
||||
CancelToken? cancelToken,
|
||||
CancellationToken? cancelToken,
|
||||
ServerInfo? serverInfo,
|
||||
List<AvailableAlbum>? availableAlbums,
|
||||
Set<AssetPathEntity>? selectedBackupAlbums,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.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> {
|
||||
@@ -19,7 +19,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
allAssetOnDatabase: const [],
|
||||
progressInPercentage: 0,
|
||||
cancelToken: CancelToken(),
|
||||
cancelToken: CancellationToken(),
|
||||
serverInfo: ServerInfo(
|
||||
diskAvailable: "0",
|
||||
diskAvailableRaw: 0,
|
||||
@@ -54,7 +54,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 +63,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();
|
||||
}
|
||||
|
||||
@@ -93,16 +95,19 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
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 +118,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 +138,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 +157,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");
|
||||
@@ -172,21 +181,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
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> allAssetOnDatabase =
|
||||
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) => !allAssetOnDatabase.contains(assetId));
|
||||
|
||||
if (allUniqueAssets.isEmpty) {
|
||||
debugPrint("No Asset On Device");
|
||||
@@ -225,7 +240,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(
|
||||
@@ -266,32 +282,42 @@ 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);
|
||||
} else {
|
||||
PhotoManager.openSetting();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}, allAssetOnDatabase: [
|
||||
...state.allAssetOnDatabase,
|
||||
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 {
|
||||
@@ -325,7 +351,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
// Check if this device is enable backup by the user
|
||||
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
||||
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");
|
||||
@@ -342,6 +369,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
}
|
||||
|
||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
final backupProvider =
|
||||
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
return BackupNotifier(ref: ref);
|
||||
});
|
||||
|
||||
@@ -8,11 +8,11 @@ import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.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;
|
||||
|
||||
class BackupService {
|
||||
final NetworkService _networkService = NetworkService();
|
||||
@@ -26,17 +26,13 @@ class BackupService {
|
||||
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());
|
||||
|
||||
backupAsset(Set<AssetEntity> assetList, http.CancellationToken cancelToken,
|
||||
Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgress) async {
|
||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
File? file;
|
||||
|
||||
MultipartFile assetRawUploadData;
|
||||
MultipartFile thumbnailUploadData;
|
||||
http.MultipartFile? thumbnailUploadData;
|
||||
|
||||
for (var entity in assetList) {
|
||||
try {
|
||||
@@ -47,35 +43,27 @@ class BackupService {
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
FormData formData;
|
||||
String originalFileName = await entity.titleAsync;
|
||||
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(
|
||||
thumbnailUploadData = http.MultipartFile.fromBytes(
|
||||
"thumbnailData",
|
||||
List.from(thumbnailData),
|
||||
filename: fileNameWithoutPath,
|
||||
contentType: MediaType(
|
||||
@@ -83,39 +71,37 @@ class BackupService {
|
||||
"jpeg",
|
||||
),
|
||||
);
|
||||
|
||||
// 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]
|
||||
});
|
||||
}
|
||||
|
||||
Response res = await dio.post(
|
||||
'$savedEndpoint/asset/upload',
|
||||
data: formData,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: (sent, total) => uploadProgress(sent, total),
|
||||
);
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
var req = MultipartRequest('POST', Uri.parse('$savedEndpoint/asset/upload'),
|
||||
onProgress: ((bytes, totalBytes) => uploadProgress(bytes, totalBytes)));
|
||||
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
|
||||
|
||||
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();
|
||||
|
||||
if (thumbnailUploadData != null) {
|
||||
req.files.add(thumbnailUploadData);
|
||||
}
|
||||
req.files.add(assetRawUploadData);
|
||||
|
||||
var res = await req.send(cancellationToken: cancelToken);
|
||||
|
||||
if (res.statusCode == 201) {
|
||||
singleAssetDoneCb(entity.id, deviceId);
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
@@ -150,3 +136,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,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(websocketProvider.notifier).stopListenToEvent('on_upload_success');
|
||||
ref
|
||||
.watch(websocketProvider.notifier)
|
||||
.stopListenToEvent('on_upload_success');
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
@@ -48,7 +53,8 @@ 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,
|
||||
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
|
||||
@@ -58,7 +64,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 +74,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
|
||||
@@ -104,10 +113,15 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
onPressed: () {
|
||||
isAutoBackup
|
||||
? ref.watch(authenticationProvider.notifier).setAutoBackup(false)
|
||||
: ref.watch(authenticationProvider.notifier).setAutoBackup(true);
|
||||
? ref
|
||||
.watch(authenticationProvider.notifier)
|
||||
.setAutoBackup(false)
|
||||
: ref
|
||||
.watch(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)),
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -133,7 +147,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 {
|
||||
@@ -141,7 +158,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -160,7 +180,10 @@ 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 {
|
||||
@@ -181,7 +204,8 @@ 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(
|
||||
@@ -258,13 +282,16 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
BackupInfoCard(
|
||||
title: "Backup",
|
||||
subtitle: "Photos and videos from selected albums that are backup",
|
||||
subtitle:
|
||||
"Photos and videos from selected albums that are backup",
|
||||
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:
|
||||
"Photos and videos that has not been backing up from selected albums",
|
||||
info:
|
||||
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||
),
|
||||
const Divider(),
|
||||
_buildBackupController(),
|
||||
@@ -289,29 +316,32 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Container(
|
||||
child: backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.red[300],
|
||||
onPrimary: Colors.grey[50],
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
},
|
||||
child: const Text("Cancel"),
|
||||
)
|
||||
: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
onPrimary: Colors.grey[50],
|
||||
),
|
||||
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],
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
},
|
||||
child: const Text("Cancel"),
|
||||
)
|
||||
: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
onPrimary: Colors.grey[50],
|
||||
),
|
||||
onPressed: shouldBackup
|
||||
? () {
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.startBackupProcess();
|
||||
}
|
||||
: null,
|
||||
child: const Text("Start Backup"),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
|
||||
class DisableMultiSelectButton extends ConsumerWidget {
|
||||
const DisableMultiSelectButton({
|
||||
@@ -36,7 +35,8 @@ class DisableMultiSelectButton extends ConsumerWidget {
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: Text(
|
||||
selectedItemCount.toString(),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -81,7 +81,8 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
scrollThumbBuilder = _thumbRRectBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
scrollThumbBuilder =
|
||||
_thumbRRectBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
super(key: key);
|
||||
|
||||
DraggableScrollbar.arrows({
|
||||
@@ -98,7 +99,8 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
scrollThumbBuilder = _thumbArrowBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
scrollThumbBuilder =
|
||||
_thumbArrowBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
super(key: key);
|
||||
|
||||
DraggableScrollbar.semicircle({
|
||||
@@ -115,11 +117,12 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
scrollThumbBuilder = _thumbSemicircleBuilder(heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
scrollThumbBuilder = _thumbSemicircleBuilder(
|
||||
heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
_DraggableScrollbarState createState() => _DraggableScrollbarState();
|
||||
DraggableScrollbarState createState() => DraggableScrollbarState();
|
||||
|
||||
static buildScrollThumbAndLabel(
|
||||
{required Widget scrollThumb,
|
||||
@@ -137,9 +140,9 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
children: [
|
||||
ScrollLabel(
|
||||
animation: labelAnimation,
|
||||
child: labelText,
|
||||
backgroundColor: backgroundColor,
|
||||
constraints: labelConstraints,
|
||||
child: labelText,
|
||||
),
|
||||
scrollThumb,
|
||||
],
|
||||
@@ -154,7 +157,8 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static ScrollThumbBuilder _thumbSemicircleBuilder(double width, Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
static ScrollThumbBuilder _thumbSemicircleBuilder(
|
||||
double width, Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
@@ -168,9 +172,6 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
foregroundPainter: ArrowCustomPainter(Colors.white),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(Size(width, height)),
|
||||
),
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(height),
|
||||
@@ -178,6 +179,9 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
topRight: const Radius.circular(4.0),
|
||||
bottomRight: const Radius.circular(4.0),
|
||||
),
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(Size(width, height)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -193,7 +197,8 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
};
|
||||
}
|
||||
|
||||
static ScrollThumbBuilder _thumbArrowBuilder(Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
static ScrollThumbBuilder _thumbArrowBuilder(
|
||||
Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
@@ -203,6 +208,7 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
BoxConstraints? labelConstraints,
|
||||
}) {
|
||||
final scrollThumb = ClipPath(
|
||||
clipper: ArrowClipper(),
|
||||
child: Container(
|
||||
height: height,
|
||||
width: 20.0,
|
||||
@@ -213,7 +219,6 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
clipper: ArrowClipper(),
|
||||
);
|
||||
|
||||
return buildScrollThumbAndLabel(
|
||||
@@ -228,7 +233,8 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
};
|
||||
}
|
||||
|
||||
static ScrollThumbBuilder _thumbRRectBuilder(Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
static ScrollThumbBuilder _thumbRRectBuilder(
|
||||
Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
@@ -239,13 +245,13 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
}) {
|
||||
final scrollThumb = Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7.0)),
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(
|
||||
Size(16.0, height),
|
||||
),
|
||||
),
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7.0)),
|
||||
);
|
||||
|
||||
return buildScrollThumbAndLabel(
|
||||
@@ -267,7 +273,8 @@ class ScrollLabel extends StatelessWidget {
|
||||
final Text child;
|
||||
|
||||
final BoxConstraints? constraints;
|
||||
static const BoxConstraints _defaultConstraints = BoxConstraints.tightFor(width: 72.0, height: 28.0);
|
||||
static const BoxConstraints _defaultConstraints =
|
||||
BoxConstraints.tightFor(width: 72.0, height: 28.0);
|
||||
|
||||
const ScrollLabel({
|
||||
Key? key,
|
||||
@@ -298,7 +305,8 @@ class ScrollLabel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin {
|
||||
class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
with TickerProviderStateMixin {
|
||||
late double _barOffset;
|
||||
late double _viewOffset;
|
||||
late bool _isDragInProcess;
|
||||
@@ -345,7 +353,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get barMaxScrollExtent => context.size!.height - widget.heightScrollThumb;
|
||||
double get barMaxScrollExtent =>
|
||||
context.size!.height - widget.heightScrollThumb;
|
||||
|
||||
double get barMinScrollExtent => 0;
|
||||
|
||||
@@ -362,7 +371,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
//print("LayoutBuilder constraints=$constraints");
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
@@ -432,7 +442,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
|
||||
if (notification is ScrollUpdateNotification ||
|
||||
notification is OverscrollNotification) {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
@@ -486,7 +497,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
||||
_barOffset = barMaxScrollExtent;
|
||||
}
|
||||
|
||||
double viewDelta = getScrollViewDelta(details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent);
|
||||
double viewDelta = getScrollViewDelta(
|
||||
details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent);
|
||||
|
||||
_viewOffset = widget.controller.position.pixels + viewDelta;
|
||||
if (_viewOffset < widget.controller.position.minScrollExtent) {
|
||||
@@ -566,7 +578,8 @@ class ArrowClipper extends CustomClipper<Path> {
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0);
|
||||
path.lineTo(
|
||||
startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0);
|
||||
path.lineTo(startPointX, startPointY + 1.0);
|
||||
path.close();
|
||||
|
||||
@@ -575,7 +588,8 @@ class ArrowClipper extends CustomClipper<Path> {
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
|
||||
path.lineTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX, startPointY - 1.0);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0);
|
||||
path.lineTo(
|
||||
startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
|
||||
path.close();
|
||||
|
||||
@@ -600,7 +614,8 @@ class SlideFadeTransition extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => animation.value == 0.0 ? Container() : child!,
|
||||
builder: (context, child) =>
|
||||
animation.value == 0.0 ? Container() : child!,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0.3, 0.0),
|
||||
|
||||
@@ -20,16 +20,18 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final BackUpState _backupState = ref.watch(backupProvider);
|
||||
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||
final ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
|
||||
final BackUpState backupState = ref.watch(backupProvider);
|
||||
bool isEnableAutoBackup =
|
||||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||
|
||||
return SliverAppBar(
|
||||
centerTitle: true,
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||
leading: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return Stack(
|
||||
@@ -47,7 +49,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
_serverInfoState.isVersionMismatch
|
||||
serverInfoState.isVersionMismatch
|
||||
? Positioned(
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
@@ -88,7 +90,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? Positioned(
|
||||
top: 10,
|
||||
right: 12,
|
||||
@@ -97,7 +99,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
width: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).primaryColor),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -105,7 +108,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
IconButton(
|
||||
splashRadius: 25,
|
||||
iconSize: 30,
|
||||
icon: _isEnableAutoBackup
|
||||
icon: isEnableAutoBackup
|
||||
? const Icon(Icons.backup_rounded)
|
||||
: Badge(
|
||||
padding: const EdgeInsets.all(4),
|
||||
@@ -118,20 +121,23 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
),
|
||||
child: const Icon(Icons.backup_rounded)),
|
||||
onPressed: () async {
|
||||
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
|
||||
var onPop = await AutoRouter.of(context)
|
||||
.push(const BackupControllerRoute());
|
||||
|
||||
if (onPop != null && onPop == true) {
|
||||
onPopBack!();
|
||||
}
|
||||
},
|
||||
),
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? Positioned(
|
||||
bottom: 5,
|
||||
child: Text(
|
||||
(_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length)
|
||||
(backupState.allUniqueAssets.length -
|
||||
backupState.selectedAlbumsBackupAssetsIds.length)
|
||||
.toString(),
|
||||
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 9, fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
: Container()
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
@@ -23,9 +24,10 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
AuthenticationState _authState = ref.watch(authenticationProvider);
|
||||
ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
|
||||
final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status;
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||
final uploadProfileImageStatus =
|
||||
ref.watch(uploadProfileImageProvider).status;
|
||||
final appInfo = useState({});
|
||||
var dummmy = Random().nextInt(1024);
|
||||
|
||||
@@ -39,7 +41,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
_buildUserProfileImage() {
|
||||
if (_authState.profileImagePath.isEmpty) {
|
||||
if (authState.profileImagePath.isEmpty) {
|
||||
return const CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
@@ -48,10 +50,11 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
|
||||
if (_authState.profileImagePath.isNotEmpty) {
|
||||
if (authState.profileImagePath.isNotEmpty) {
|
||||
return CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: NetworkImage('$endpoint/user/profile-image/${_authState.userId}?d=${dummmy++}'),
|
||||
backgroundImage: NetworkImage(
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
} else {
|
||||
@@ -66,7 +69,8 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.success) {
|
||||
return CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: NetworkImage('$endpoint/user/profile-image/${_authState.userId}?d=${dummmy++}'),
|
||||
backgroundImage: NetworkImage(
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
@@ -87,15 +91,16 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
_pickUserProfileImage() async {
|
||||
final XFile? image = await ImagePicker().pickImage(source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024);
|
||||
final XFile? image = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024);
|
||||
|
||||
if (image != null) {
|
||||
var success = await ref.watch(uploadProfileImageProvider.notifier).upload(image);
|
||||
var success =
|
||||
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
|
||||
|
||||
if (success) {
|
||||
ref
|
||||
.watch(authenticationProvider.notifier)
|
||||
.updateUserProfileImagePath(ref.read(uploadProfileImageProvider).profileImagePath);
|
||||
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
|
||||
ref.read(uploadProfileImageProvider).profileImagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +121,10 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
DrawerHeader(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color.fromARGB(255, 216, 219, 238), Color.fromARGB(255, 226, 230, 231)],
|
||||
colors: [
|
||||
Color.fromARGB(255, 216, 219, 238),
|
||||
Color.fromARGB(255, 226, 230, 231)
|
||||
],
|
||||
begin: Alignment.centerRight,
|
||||
end: Alignment.centerLeft,
|
||||
),
|
||||
@@ -154,7 +162,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"${_authState.firstName} ${_authState.lastName}",
|
||||
"${authState.firstName} ${authState.lastName}",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -162,7 +170,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_authState.userEmail,
|
||||
authState.userEmail,
|
||||
style: TextStyle(color: Colors.grey[800], fontSize: 12),
|
||||
)
|
||||
],
|
||||
@@ -176,16 +184,21 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
),
|
||||
title: const Text(
|
||||
"Sign Out",
|
||||
style: TextStyle(color: Colors.black54, fontSize: 14, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Colors.black54,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
onTap: () async {
|
||||
bool res = await ref.read(authenticationProvider.notifier).logout();
|
||||
bool res =
|
||||
await ref.read(authenticationProvider.notifier).logout();
|
||||
|
||||
if (res) {
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
ref.watch(websocketProvider.notifier).disconnect();
|
||||
AutoRouter.of(context).popUntilRoot();
|
||||
// AutoRouter.of(context).popUntilRoot();
|
||||
AutoRouter.of(context).replace(const LoginRoute());
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -204,19 +217,22 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
_serverInfoState.isVersionMismatch
|
||||
? _serverInfoState.versionMismatchErrorMessage
|
||||
serverInfoState.isVersionMismatch
|
||||
? serverInfoState.versionMismatchErrorMessage
|
||||
: "Client and Server are up-to-date",
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: Theme.of(context).primaryColor, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
@@ -254,7 +270,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${_serverInfoState.serverVersion.major}.${_serverInfoState.serverVersion.minor}.${_serverInfoState.serverVersion.patch}",
|
||||
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
|
||||
@@ -19,10 +19,11 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ScrollController _scrollController = useScrollController();
|
||||
ScrollController scrollController = useScrollController();
|
||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||
List<Widget> _imageGridGroup = [];
|
||||
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
List<Widget> imageGridGroup = [];
|
||||
var isMultiSelectEnable =
|
||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
var homePageState = ref.watch(homePageStateProvider);
|
||||
|
||||
useEffect(() {
|
||||
@@ -39,7 +40,8 @@ class HomePage extends HookConsumerWidget {
|
||||
_buildSelectedItemCountIndicator() {
|
||||
return isMultiSelectEnable
|
||||
? DisableMultiSelectButton(
|
||||
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
||||
onPressed:
|
||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
||||
selectedItemCount: homePageState.selectedItems.length,
|
||||
)
|
||||
: Container();
|
||||
@@ -59,7 +61,7 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
if (lastMonth != null) {
|
||||
if (currentMonth - lastMonth! != 0) {
|
||||
_imageGridGroup.add(
|
||||
imageGridGroup.add(
|
||||
MonthlyTitleText(
|
||||
isoDate: dateGroup,
|
||||
),
|
||||
@@ -67,14 +69,14 @@ class HomePage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
_imageGridGroup.add(
|
||||
imageGridGroup.add(
|
||||
DailyTitleText(
|
||||
isoDate: dateGroup,
|
||||
assetGroup: immichAssetList,
|
||||
),
|
||||
);
|
||||
|
||||
_imageGridGroup.add(
|
||||
imageGridGroup.add(
|
||||
ImageGrid(assetGroup: immichAssetList),
|
||||
);
|
||||
|
||||
@@ -109,12 +111,12 @@ class HomePage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 50.0),
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
..._imageGridGroup,
|
||||
...imageGridGroup,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -67,7 +67,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
enableFeedback: true,
|
||||
title: const Text(
|
||||
"Save login",
|
||||
"Stay logged in",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey),
|
||||
),
|
||||
value: isSaveLoginInfo.value,
|
||||
@@ -96,12 +96,20 @@ class ServerEndpointInput extends StatelessWidget {
|
||||
|
||||
const ServerEndpointInput({Key? key, required this.controller}) : super(key: key);
|
||||
|
||||
String? _validateInput(String? url) {
|
||||
if (url == null) return null;
|
||||
if (!url.startsWith(RegExp(r'https?://'))) return 'Please specify http:// or https://';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server Endpoint URL', border: OutlineInputBorder(), hintText: 'http://your-server-ip:port'),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,12 +119,22 @@ class EmailInput extends StatelessWidget {
|
||||
|
||||
const EmailInput({Key? key, required this.controller}) : super(key: key);
|
||||
|
||||
String? _validateInput(String? email) {
|
||||
if (email == null || email == '') return null;
|
||||
if (email.endsWith(' ')) return 'Trailing whitespace';
|
||||
if (email.startsWith(' ')) return 'Leading whitespace';
|
||||
if (email.contains(' ') || !email.contains('@')) return 'Invalid Email';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Email', border: OutlineInputBorder(), hintText: 'youremail@email.com'),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,20 +45,23 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
|
||||
}
|
||||
|
||||
void getSuggestedSearchTerms() async {
|
||||
var userSuggestedSearchTerms = await _searchService.getUserSuggestedSearchTerms();
|
||||
var userSuggestedSearchTerms =
|
||||
await _searchService.getUserSuggestedSearchTerms();
|
||||
|
||||
state = state.copyWith(userSuggestedSearchTerms: userSuggestedSearchTerms);
|
||||
}
|
||||
}
|
||||
|
||||
final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
|
||||
final searchPageStateProvider =
|
||||
StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
|
||||
return SearchPageStateNotifier();
|
||||
});
|
||||
|
||||
final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocation>>((ref) async {
|
||||
final SearchService _searchService = SearchService();
|
||||
final getCuratedLocationProvider =
|
||||
FutureProvider.autoDispose<List<CuratedLocation>>((ref) async {
|
||||
final SearchService searchService = SearchService();
|
||||
|
||||
var curatedLocation = await _searchService.getCuratedLocation();
|
||||
var curatedLocation = await searchService.getCuratedLocation();
|
||||
if (curatedLocation != null) {
|
||||
return curatedLocation;
|
||||
} else {
|
||||
@@ -66,10 +69,11 @@ final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocati
|
||||
}
|
||||
});
|
||||
|
||||
final getCuratedObjectProvider = FutureProvider.autoDispose<List<CuratedObject>>((ref) async {
|
||||
final SearchService _searchService = SearchService();
|
||||
final getCuratedObjectProvider =
|
||||
FutureProvider.autoDispose<List<CuratedObject>>((ref) async {
|
||||
final SearchService searchService = SearchService();
|
||||
|
||||
var curatedObject = await _searchService.getCuratedObjects();
|
||||
var curatedObject = await searchService.getCuratedObjects();
|
||||
if (curatedObject != null) {
|
||||
return curatedObject;
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
|
||||
@@ -12,24 +12,27 @@ import 'package:immich_mobile/modules/search/providers/search_result_page.provid
|
||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||
|
||||
class SearchResultPage extends HookConsumerWidget {
|
||||
SearchResultPage({Key? key, required this.searchTerm}) : super(key: key);
|
||||
const SearchResultPage({Key? key, required this.searchTerm})
|
||||
: super(key: key);
|
||||
|
||||
final String searchTerm;
|
||||
late FocusNode searchFocusNode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ScrollController _scrollController = useScrollController();
|
||||
ScrollController scrollController = useScrollController();
|
||||
final searchTermController = useTextEditingController(text: "");
|
||||
final isNewSearch = useState(false);
|
||||
final currentSearchTerm = useState(searchTerm);
|
||||
|
||||
List<Widget> _imageGridGroup = [];
|
||||
final List<Widget> imageGridGroup = [];
|
||||
|
||||
late FocusNode searchFocusNode;
|
||||
|
||||
useEffect(() {
|
||||
searchFocusNode = FocusNode();
|
||||
|
||||
Future.delayed(Duration.zero, () => ref.read(searchResultPageProvider.notifier).search(searchTerm));
|
||||
Future.delayed(Duration.zero,
|
||||
() => ref.read(searchResultPageProvider.notifier).search(searchTerm));
|
||||
return () => searchFocusNode.dispose();
|
||||
}, []);
|
||||
|
||||
@@ -85,7 +88,10 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
currentSearchTerm.value,
|
||||
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
),
|
||||
Icon(
|
||||
@@ -124,7 +130,7 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
|
||||
if (lastMonth != null) {
|
||||
if (currentMonth - lastMonth! != 0) {
|
||||
_imageGridGroup.add(
|
||||
imageGridGroup.add(
|
||||
MonthlyTitleText(
|
||||
isoDate: dateGroup,
|
||||
),
|
||||
@@ -132,14 +138,14 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
_imageGridGroup.add(
|
||||
imageGridGroup.add(
|
||||
DailyTitleText(
|
||||
isoDate: dateGroup,
|
||||
assetGroup: immichAssetList,
|
||||
),
|
||||
);
|
||||
|
||||
_imageGridGroup.add(
|
||||
imageGridGroup.add(
|
||||
ImageGrid(assetGroup: immichAssetList),
|
||||
);
|
||||
|
||||
@@ -148,11 +154,11 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
|
||||
return DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [..._imageGridGroup],
|
||||
controller: scrollController,
|
||||
slivers: [...imageGridGroup],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -192,7 +198,9 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildSearchResult(),
|
||||
isNewSearch.value ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
|
||||
isNewSearch.value
|
||||
? SearchSuggestionList(onSubmitted: _onSearchSubmitted)
|
||||
: Container(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:immich_mobile/modules/sharing/models/shared_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/shared_user.model.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/models/user.model.dart';
|
||||
|
||||
class SharedAlbum {
|
||||
final String id;
|
||||
@@ -11,8 +11,8 @@ class SharedAlbum {
|
||||
final String albumName;
|
||||
final String createdAt;
|
||||
final String? albumThumbnailAssetId;
|
||||
final List<SharedUsers> sharedUsers;
|
||||
final List<SharedAssets>? sharedAssets;
|
||||
final List<User> sharedUsers;
|
||||
final List<ImmichAsset>? assets;
|
||||
|
||||
SharedAlbum({
|
||||
required this.id,
|
||||
@@ -21,7 +21,7 @@ class SharedAlbum {
|
||||
required this.createdAt,
|
||||
required this.albumThumbnailAssetId,
|
||||
required this.sharedUsers,
|
||||
this.sharedAssets,
|
||||
this.assets,
|
||||
});
|
||||
|
||||
SharedAlbum copyWith({
|
||||
@@ -30,8 +30,8 @@ class SharedAlbum {
|
||||
String? albumName,
|
||||
String? createdAt,
|
||||
String? albumThumbnailAssetId,
|
||||
List<SharedUsers>? sharedUsers,
|
||||
List<SharedAssets>? sharedAssets,
|
||||
List<User>? sharedUsers,
|
||||
List<ImmichAsset>? assets,
|
||||
}) {
|
||||
return SharedAlbum(
|
||||
id: id ?? this.id,
|
||||
@@ -40,7 +40,7 @@ class SharedAlbum {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId,
|
||||
sharedUsers: sharedUsers ?? this.sharedUsers,
|
||||
sharedAssets: sharedAssets ?? this.sharedAssets,
|
||||
assets: assets ?? this.assets,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ class SharedAlbum {
|
||||
result.addAll({'albumThumbnailAssetId': albumThumbnailAssetId});
|
||||
}
|
||||
result.addAll({'sharedUsers': sharedUsers.map((x) => x.toMap()).toList()});
|
||||
if (sharedAssets != null) {
|
||||
result.addAll({'sharedAssets': sharedAssets!.map((x) => x.toMap()).toList()});
|
||||
if (assets != null) {
|
||||
result.addAll({'assets': assets!.map((x) => x.toMap()).toList()});
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -69,9 +69,9 @@ class SharedAlbum {
|
||||
albumName: map['albumName'] ?? '',
|
||||
createdAt: map['createdAt'] ?? '',
|
||||
albumThumbnailAssetId: map['albumThumbnailAssetId'],
|
||||
sharedUsers: List<SharedUsers>.from(map['sharedUsers']?.map((x) => SharedUsers.fromMap(x))),
|
||||
sharedAssets: map['sharedAssets'] != null
|
||||
? List<SharedAssets>.from(map['sharedAssets']?.map((x) => SharedAssets.fromMap(x)))
|
||||
sharedUsers: List<User>.from(map['sharedUsers']?.map((x) => User.fromMap(x))),
|
||||
assets: map['assets'] != null
|
||||
? List<ImmichAsset>.from(map['assets']?.map((x) => ImmichAsset.fromMap(x)))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ class SharedAlbum {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, sharedAssets: $sharedAssets)';
|
||||
return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, assets: $assets)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -97,7 +97,7 @@ class SharedAlbum {
|
||||
other.createdAt == createdAt &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
listEquals(other.sharedUsers, sharedUsers) &&
|
||||
listEquals(other.sharedAssets, sharedAssets);
|
||||
listEquals(other.assets, assets);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -108,6 +108,6 @@ class SharedAlbum {
|
||||
createdAt.hashCode ^
|
||||
albumThumbnailAssetId.hashCode ^
|
||||
sharedUsers.hashCode ^
|
||||
sharedAssets.hashCode;
|
||||
assets.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class SharedAssets {
|
||||
final ImmichAsset assetInfo;
|
||||
|
||||
SharedAssets({
|
||||
required this.assetInfo,
|
||||
});
|
||||
|
||||
SharedAssets copyWith({
|
||||
ImmichAsset? assetInfo,
|
||||
}) {
|
||||
return SharedAssets(
|
||||
assetInfo: assetInfo ?? this.assetInfo,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'assetInfo': assetInfo.toMap()});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory SharedAssets.fromMap(Map<String, dynamic> map) {
|
||||
return SharedAssets(
|
||||
assetInfo: ImmichAsset.fromMap(map['assetInfo']),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SharedAssets.fromJson(String source) => SharedAssets.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'SharedAssets(assetInfo: $assetInfo)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is SharedAssets && other.assetInfo == assetInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetInfo.hashCode;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||
|
||||
class SharedUsers {
|
||||
final int id;
|
||||
final String albumId;
|
||||
final String sharedUserId;
|
||||
final UserInfo userInfo;
|
||||
|
||||
SharedUsers({
|
||||
required this.id,
|
||||
required this.albumId,
|
||||
required this.sharedUserId,
|
||||
required this.userInfo,
|
||||
});
|
||||
|
||||
SharedUsers copyWith({
|
||||
int? id,
|
||||
String? albumId,
|
||||
String? sharedUserId,
|
||||
UserInfo? userInfo,
|
||||
}) {
|
||||
return SharedUsers(
|
||||
id: id ?? this.id,
|
||||
albumId: albumId ?? this.albumId,
|
||||
sharedUserId: sharedUserId ?? this.sharedUserId,
|
||||
userInfo: userInfo ?? this.userInfo,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'id': id});
|
||||
result.addAll({'albumId': albumId});
|
||||
result.addAll({'sharedUserId': sharedUserId});
|
||||
result.addAll({'userInfo': userInfo.toMap()});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory SharedUsers.fromMap(Map<String, dynamic> map) {
|
||||
return SharedUsers(
|
||||
id: map['id']?.toInt() ?? 0,
|
||||
albumId: map['albumId'] ?? '',
|
||||
sharedUserId: map['sharedUserId'] ?? '',
|
||||
userInfo: UserInfo.fromMap(map['userInfo']),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SharedUsers.fromJson(String source) => SharedUsers.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SharedUsers(id: $id, albumId: $albumId, sharedUserId: $sharedUserId, userInfo: $userInfo)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is SharedUsers &&
|
||||
other.id == id &&
|
||||
other.albumId == albumId &&
|
||||
other.sharedUserId == sharedUserId &&
|
||||
other.userInfo == userInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ albumId.hashCode ^ sharedUserId.hashCode ^ userInfo.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
|
||||
final SharedAlbumService _sharedAlbumService = SharedAlbumService();
|
||||
|
||||
getAllSharedAlbums() async {
|
||||
List<SharedAlbum> sharedAlbums = await _sharedAlbumService.getAllSharedAlbum();
|
||||
List<SharedAlbum> sharedAlbums =
|
||||
await _sharedAlbumService.getAllSharedAlbum();
|
||||
|
||||
state = sharedAlbums;
|
||||
}
|
||||
@@ -35,7 +36,8 @@ class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async {
|
||||
Future<bool> removeAssetFromAlbum(
|
||||
String albumId, List<String> assetIds) async {
|
||||
var res = await _sharedAlbumService.removeAssetFromAlbum(albumId, assetIds);
|
||||
|
||||
if (res) {
|
||||
@@ -46,12 +48,14 @@ class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
|
||||
}
|
||||
}
|
||||
|
||||
final sharedAlbumProvider = StateNotifierProvider<SharedAlbumNotifier, List<SharedAlbum>>((ref) {
|
||||
final sharedAlbumProvider =
|
||||
StateNotifierProvider<SharedAlbumNotifier, List<SharedAlbum>>((ref) {
|
||||
return SharedAlbumNotifier();
|
||||
});
|
||||
|
||||
final sharedAlbumDetailProvider = FutureProvider.autoDispose.family<SharedAlbum, String>((ref, albumId) async {
|
||||
final SharedAlbumService _sharedAlbumService = SharedAlbumService();
|
||||
final sharedAlbumDetailProvider = FutureProvider.autoDispose
|
||||
.family<SharedAlbum, String>((ref, albumId) async {
|
||||
final SharedAlbumService sharedAlbumService = SharedAlbumService();
|
||||
|
||||
return await _sharedAlbumService.getAlbumDetail(albumId);
|
||||
return await sharedAlbumService.getAlbumDetail(albumId);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/user.model.dart';
|
||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||
|
||||
final suggestedSharedUsersProvider = FutureProvider.autoDispose<List<UserInfo>>((ref) async {
|
||||
final suggestedSharedUsersProvider = FutureProvider.autoDispose<List<User>>((ref) async {
|
||||
UserService userService = UserService();
|
||||
|
||||
return await userService.getAllUsersInfo();
|
||||
|
||||
@@ -12,9 +12,10 @@ class SharedAlbumService {
|
||||
|
||||
Future<List<SharedAlbum>> getAllSharedAlbum() async {
|
||||
try {
|
||||
var res = await _networkService.getRequest(url: 'shared/allSharedAlbums');
|
||||
var res = await _networkService.getRequest(url: 'album?shared=true');
|
||||
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||
List<SharedAlbum> result = List.from(decodedData.map((e) => SharedAlbum.fromMap(e)));
|
||||
List<SharedAlbum> result =
|
||||
List.from(decodedData.map((e) => SharedAlbum.fromMap(e)));
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
@@ -24,9 +25,10 @@ class SharedAlbumService {
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<bool> createSharedAlbum(String albumName, Set<ImmichAsset> assets, List<String> sharedUserIds) async {
|
||||
Future<bool> createSharedAlbum(String albumName, Set<ImmichAsset> assets,
|
||||
List<String> sharedUserIds) async {
|
||||
try {
|
||||
var res = await _networkService.postRequest(url: 'shared/createAlbum', data: {
|
||||
var res = await _networkService.postRequest(url: 'album', data: {
|
||||
"albumName": albumName,
|
||||
"sharedWithUserIds": sharedUserIds,
|
||||
"assetIds": assets.map((asset) => asset.id).toList(),
|
||||
@@ -45,7 +47,7 @@ class SharedAlbumService {
|
||||
|
||||
Future<SharedAlbum> getAlbumDetail(String albumId) async {
|
||||
try {
|
||||
var res = await _networkService.getRequest(url: 'shared/$albumId');
|
||||
var res = await _networkService.getRequest(url: 'album/$albumId');
|
||||
dynamic decodedData = jsonDecode(res.toString());
|
||||
SharedAlbum result = SharedAlbum.fromMap(decodedData);
|
||||
|
||||
@@ -55,9 +57,11 @@ class SharedAlbumService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> addAdditionalAssetToAlbum(Set<ImmichAsset> assets, String albumId) async {
|
||||
Future<bool> addAdditionalAssetToAlbum(
|
||||
Set<ImmichAsset> assets, String albumId) async {
|
||||
try {
|
||||
var res = await _networkService.postRequest(url: 'shared/addAssets', data: {
|
||||
var res =
|
||||
await _networkService.putRequest(url: 'album/$albumId/assets', data: {
|
||||
"albumId": albumId,
|
||||
"assetIds": assets.map((asset) => asset.id).toList(),
|
||||
});
|
||||
@@ -73,10 +77,11 @@ class SharedAlbumService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> addAdditionalUserToAlbum(List<String> sharedUserIds, String albumId) async {
|
||||
Future<bool> addAdditionalUserToAlbum(
|
||||
List<String> sharedUserIds, String albumId) async {
|
||||
try {
|
||||
var res = await _networkService.postRequest(url: 'shared/addUsers', data: {
|
||||
"albumId": albumId,
|
||||
var res =
|
||||
await _networkService.putRequest(url: 'album/$albumId/users', data: {
|
||||
"sharedUserIds": sharedUserIds,
|
||||
});
|
||||
|
||||
@@ -93,7 +98,7 @@ class SharedAlbumService {
|
||||
|
||||
Future<bool> deleteAlbum(String albumId) async {
|
||||
try {
|
||||
Response res = await _networkService.deleteRequest(url: 'shared/$albumId');
|
||||
Response res = await _networkService.deleteRequest(url: 'album/$albumId');
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
return false;
|
||||
@@ -108,7 +113,8 @@ class SharedAlbumService {
|
||||
|
||||
Future<bool> leaveAlbum(String albumId) async {
|
||||
try {
|
||||
Response res = await _networkService.deleteRequest(url: 'shared/leaveAlbum/$albumId');
|
||||
Response res =
|
||||
await _networkService.deleteRequest(url: 'album/$albumId/user/me');
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
return false;
|
||||
@@ -121,10 +127,11 @@ class SharedAlbumService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async {
|
||||
Future<bool> removeAssetFromAlbum(
|
||||
String albumId, List<String> assetIds) async {
|
||||
try {
|
||||
Response res = await _networkService.deleteRequest(url: 'shared/removeAssets/', data: {
|
||||
"albumId": albumId,
|
||||
Response res = await _networkService
|
||||
.deleteRequest(url: 'album/$albumId/assets', data: {
|
||||
"assetIds": assetIds,
|
||||
});
|
||||
|
||||
@@ -139,10 +146,11 @@ class SharedAlbumService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> changeTitleAlbum(String albumId, String ownerId, String newAlbumTitle) async {
|
||||
Future<bool> changeTitleAlbum(
|
||||
String albumId, String ownerId, String newAlbumTitle) async {
|
||||
try {
|
||||
Response res = await _networkService.patchRequest(url: 'shared/updateInfo', data: {
|
||||
"albumId": albumId,
|
||||
Response res =
|
||||
await _networkService.patchRequest(url: 'album/$albumId/', data: {
|
||||
"ownerId": ownerId,
|
||||
"albumName": newAlbumTitle,
|
||||
});
|
||||
|
||||
@@ -26,18 +26,22 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
|
||||
final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||
final isMultiSelectionEnable =
|
||||
ref.watch(assetSelectionProvider).isMultiselectEnable;
|
||||
final selectedAssetsInAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
|
||||
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||
|
||||
void _onDeleteAlbumPressed(String albumId) async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(albumId);
|
||||
bool isSuccess =
|
||||
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(albumId);
|
||||
|
||||
if (isSuccess) {
|
||||
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
@@ -53,10 +57,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
void _onLeaveAlbumPressed(String albumId) async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(albumId);
|
||||
bool isSuccess =
|
||||
await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(albumId);
|
||||
|
||||
if (isSuccess) {
|
||||
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
ImmichToast.show(
|
||||
@@ -73,10 +79,11 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
void _onRemoveFromAlbumPressed(String albumId) async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
|
||||
albumId,
|
||||
selectedAssetsInAlbum.map((a) => a.id).toList(),
|
||||
);
|
||||
bool isSuccess =
|
||||
await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
|
||||
albumId,
|
||||
selectedAssetsInAlbum.map((a) => a.id).toList(),
|
||||
);
|
||||
|
||||
if (isSuccess) {
|
||||
Navigator.pop(context);
|
||||
@@ -153,15 +160,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
_buildLeadingButton() {
|
||||
if (isMultiSelectionEnable) {
|
||||
return IconButton(
|
||||
onPressed: () => ref.watch(assetSelectionProvider.notifier).disableMultiselection(),
|
||||
onPressed: () => ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.disableMultiselection(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
splashRadius: 25,
|
||||
);
|
||||
} else if (isEditAlbum) {
|
||||
return IconButton(
|
||||
onPressed: () async {
|
||||
bool isSuccess =
|
||||
await ref.watch(albumViewerProvider.notifier).changeAlbumTitle(albumId, userId, newAlbumTitle);
|
||||
bool isSuccess = await ref
|
||||
.watch(albumViewerProvider.notifier)
|
||||
.changeAlbumTitle(albumId, userId, newAlbumTitle);
|
||||
|
||||
if (!isSuccess) {
|
||||
ImmichToast.show(
|
||||
@@ -187,7 +197,9 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
return AppBar(
|
||||
elevation: 0,
|
||||
leading: _buildLeadingButton(),
|
||||
title: isMultiSelectionEnable ? Text(selectedAssetsInAlbum.length.toString()) : Container(),
|
||||
title: isMultiSelectionEnable
|
||||
? Text(selectedAssetsInAlbum.length.toString())
|
||||
: Container(),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
|
||||
@@ -86,63 +86,60 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Hero(
|
||||
tag: asset.id,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
width: 150,
|
||||
height: 150,
|
||||
memCacheHeight: asset.type == 'IMAGE' ? 150 : 150,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
width: 150,
|
||||
height: 150,
|
||||
memCacheHeight: asset.type == 'IMAGE' ? 150 : 150,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _buildSelectionIcon(asset),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _buildSelectionIcon(asset),
|
||||
),
|
||||
asset.type == 'IMAGE'
|
||||
? Container()
|
||||
: Positioned(
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
),
|
||||
asset.type == 'IMAGE'
|
||||
? Container()
|
||||
: Positioned(
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,32 +23,29 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
onTap: () {
|
||||
// debugPrint("View ${asset.id}");
|
||||
},
|
||||
child: Hero(
|
||||
tag: asset.id,
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
width: 500,
|
||||
height: 500,
|
||||
memCacheHeight: asset.type == 'IMAGE' ? 500 : 500,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
width: 500,
|
||||
height: 500,
|
||||
memCacheHeight: asset.type == 'IMAGE' ? 500 : 500,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
],
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,32 +28,33 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
FocusNode titleFocusNode = useFocusNode();
|
||||
ScrollController _scrollController = useScrollController();
|
||||
AsyncValue<SharedAlbum> _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId));
|
||||
ScrollController scrollController = useScrollController();
|
||||
AsyncValue<SharedAlbum> albumInfo =
|
||||
ref.watch(sharedAlbumDetailProvider(albumId));
|
||||
|
||||
final userId = ref.watch(authenticationProvider).userId;
|
||||
|
||||
/// Find out if the assets in album exist on the device
|
||||
/// If they exist, add to selected asset state to show they are already selected.
|
||||
void _onAddPhotosPressed(SharedAlbum albumInfo) async {
|
||||
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addNewAssets(albumInfo.sharedAssets!.map((e) => e.assetInfo).toList());
|
||||
.addNewAssets(albumInfo.assets!.toList());
|
||||
}
|
||||
|
||||
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
|
||||
|
||||
AssetSelectionPageResult? returnPayload =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
AssetSelectionPageResult? returnPayload = await AutoRouter.of(context)
|
||||
.push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
|
||||
if (returnPayload != null) {
|
||||
// Check if there is new assets add
|
||||
if (returnPayload.selectedAdditionalAsset.isNotEmpty) {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
var isSuccess =
|
||||
await SharedAlbumService().addAdditionalAssetToAlbum(returnPayload.selectedAdditionalAsset, albumId);
|
||||
var isSuccess = await SharedAlbumService().addAdditionalAssetToAlbum(
|
||||
returnPayload.selectedAdditionalAsset, albumId);
|
||||
|
||||
if (isSuccess) {
|
||||
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||
@@ -69,13 +70,15 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void _onAddUsersPressed(SharedAlbum albumInfo) async {
|
||||
List<String>? sharedUserIds =
|
||||
await AutoRouter.of(context).push<List<String>?>(SelectAdditionalUserForSharingRoute(albumInfo: albumInfo));
|
||||
List<String>? sharedUserIds = await AutoRouter.of(context)
|
||||
.push<List<String>?>(
|
||||
SelectAdditionalUserForSharingRoute(albumInfo: albumInfo));
|
||||
|
||||
if (sharedUserIds != null) {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
var isSuccess = await SharedAlbumService().addAdditionalUserToAlbum(sharedUserIds, albumId);
|
||||
var isSuccess = await SharedAlbumService()
|
||||
.addAdditionalUserToAlbum(sharedUserIds, albumId);
|
||||
|
||||
if (isSuccess) {
|
||||
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||
@@ -95,16 +98,20 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(albumInfo.albumName, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
child: Text(albumInfo.albumName,
|
||||
style: const TextStyle(
|
||||
fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
|
||||
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
|
||||
String startDate = "";
|
||||
DateTime parsedStartDate = DateTime.parse(albumInfo.sharedAssets!.first.assetInfo.createdAt);
|
||||
DateTime parsedEndDate = DateTime.parse(albumInfo.sharedAssets!.last.assetInfo.createdAt);
|
||||
DateTime parsedStartDate =
|
||||
DateTime.parse(albumInfo.assets!.first.createdAt);
|
||||
DateTime parsedEndDate =
|
||||
DateTime.parse(albumInfo.assets!.last.createdAt);
|
||||
|
||||
if (parsedStartDate.year == parsedEndDate.year) {
|
||||
startDate = DateFormat('LLL d').format(parsedStartDate);
|
||||
@@ -118,7 +125,8 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8),
|
||||
child: Text(
|
||||
"$startDate-$endDate",
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey),
|
||||
style: const TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -147,8 +155,9 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ClipRRect(
|
||||
child: Image.asset('assets/immich-logo-no-outline.png'),
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
child:
|
||||
Image.asset('assets/immich-logo-no-outline.png'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -163,7 +172,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildImageGrid(SharedAlbum albumInfo) {
|
||||
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
sliver: SliverGrid(
|
||||
@@ -174,9 +183,9 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return AlbumViewerThumbnail(asset: albumInfo.sharedAssets![index].assetInfo);
|
||||
return AlbumViewerThumbnail(asset: albumInfo.assets![index]);
|
||||
},
|
||||
childCount: albumInfo.sharedAssets?.length,
|
||||
childCount: albumInfo.assets?.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -217,10 +226,10 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
},
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
_buildHeader(albumInfo),
|
||||
SliverPersistentHeader(
|
||||
@@ -242,8 +251,9 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AlbumViewerAppbar(albumInfo: _albumInfo, userId: userId, albumId: albumId),
|
||||
body: _albumInfo.when(
|
||||
appBar: AlbumViewerAppbar(
|
||||
albumInfo: albumInfo, userId: userId, albumId: albumId),
|
||||
body: albumInfo.when(
|
||||
data: (albumInfo) => _buildBody(albumInfo),
|
||||
error: (e, _) => Center(child: Text("Error loading album info $e")),
|
||||
loading: () => const Center(
|
||||
|
||||
@@ -13,13 +13,15 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
const AssetSelectionPage({Key? key}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ScrollController _scrollController = useScrollController();
|
||||
ScrollController scrollController = useScrollController();
|
||||
var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider);
|
||||
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
final selectedAssets =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final newAssetsForAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
|
||||
List<Widget> _imageGridGroup = [];
|
||||
List<Widget> imageGridGroup = [];
|
||||
|
||||
String _buildAssetCountText() {
|
||||
if (isAlbumExist) {
|
||||
@@ -31,19 +33,20 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
|
||||
Widget _buildBody() {
|
||||
assetGroupMonthYear.forEach((monthYear, assetGroup) {
|
||||
_imageGridGroup.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
|
||||
_imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup));
|
||||
imageGridGroup
|
||||
.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
|
||||
imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup));
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [..._imageGridGroup],
|
||||
controller: scrollController,
|
||||
slivers: [...imageGridGroup],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -71,7 +74,8 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
(!isAlbumExist && selectedAssets.isNotEmpty) || (isAlbumExist && newAssetsForAlbum.isNotEmpty)
|
||||
(!isAlbumExist && selectedAssets.isNotEmpty) ||
|
||||
(isAlbumExist && newAssetsForAlbum.isNotEmpty)
|
||||
? TextButton(
|
||||
onPressed: () {
|
||||
var payload = AssetSelectionPageResult(
|
||||
|
||||
@@ -15,11 +15,13 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albumTitleController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final albumTitleController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final albumTitleTextFieldFocusNode = useFocusNode();
|
||||
final isAlbumTitleTextFieldFocus = useState(false);
|
||||
final isAlbumTitleEmpty = useState(true);
|
||||
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final selectedAssets =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
|
||||
_showSelectUserPage() {
|
||||
AutoRouter.of(context).push(const SelectUserForSharingRoute());
|
||||
@@ -38,8 +40,8 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
||||
_onSelectPhotosButtonPressed() async {
|
||||
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
|
||||
|
||||
AssetSelectionPageResult? selectedAsset =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context)
|
||||
.push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
|
||||
if (selectedAsset == null) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
@@ -84,16 +86,22 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(vertical: 22, horizontal: 16),
|
||||
side: const BorderSide(color: Color.fromARGB(255, 206, 206, 206)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5))),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 22, horizontal: 16),
|
||||
side: const BorderSide(
|
||||
color: Color.fromARGB(255, 206, 206, 206)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5))),
|
||||
onPressed: _onSelectPhotosButtonPressed,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
'Select Photos',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[700], fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -141,7 +149,8 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
||||
(BuildContext context, int index) {
|
||||
return GestureDetector(
|
||||
onTap: _onBackgroundTapped,
|
||||
child: SharedAlbumThumbnailImage(asset: selectedAssets.toList()[index]),
|
||||
child: SharedAlbumThumbnailImage(
|
||||
asset: selectedAssets.toList()[index]),
|
||||
);
|
||||
},
|
||||
childCount: selectedAssets.length,
|
||||
@@ -169,7 +178,9 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: albumTitleController.text.isNotEmpty ? _showSelectUserPage : null,
|
||||
onPressed: albumTitleController.text.isNotEmpty
|
||||
? _showSelectUserPage
|
||||
: null,
|
||||
child: const Text(
|
||||
'Share',
|
||||
style: TextStyle(
|
||||
@@ -189,13 +200,13 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
||||
pinned: true,
|
||||
floating: false,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(66.0),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTitleInputField(),
|
||||
_buildControlButton(),
|
||||
],
|
||||
),
|
||||
preferredSize: const Size.fromHeight(66.0),
|
||||
),
|
||||
),
|
||||
_buildTitle(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/user.model.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
@@ -14,14 +14,14 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
|
||||
final sharedUsersList = useState<Set<UserInfo>>({});
|
||||
AsyncValue<List<User>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
|
||||
final sharedUsersList = useState<Set<User>>({});
|
||||
|
||||
_addNewUsersHandler() {
|
||||
AutoRouter.of(context).pop(sharedUsersList.value.map((e) => e.id).toList());
|
||||
}
|
||||
|
||||
_buildTileIcon(UserInfo user) {
|
||||
_buildTileIcon(User user) {
|
||||
if (sharedUsersList.value.contains(user)) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
@@ -38,7 +38,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
_buildUserList(List<UserInfo> users) {
|
||||
_buildUserList(List<User> users) {
|
||||
List<Widget> usersChip = [];
|
||||
|
||||
for (var user in sharedUsersList.value) {
|
||||
@@ -120,7 +120,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
body: suggestedShareUsers.when(
|
||||
data: (users) {
|
||||
for (var sharedUsers in albumInfo.sharedUsers) {
|
||||
users.removeWhere((u) => u.id == sharedUsers.sharedUserId || u.id == albumInfo.ownerId);
|
||||
users.removeWhere((u) => u.id == sharedUsers.id || u.id == albumInfo.ownerId);
|
||||
}
|
||||
|
||||
return _buildUserList(users);
|
||||
|
||||
@@ -8,15 +8,15 @@ import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.da
|
||||
import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/user.model.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
const SelectUserForSharingPage({Key? key}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sharedUsersList = useState<Set<UserInfo>>({});
|
||||
AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
|
||||
final sharedUsersList = useState<Set<User>>({});
|
||||
AsyncValue<List<User>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
|
||||
|
||||
_createSharedAlbum() async {
|
||||
var isSuccess = await SharedAlbumService().createSharedAlbum(
|
||||
@@ -36,7 +36,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
const ScaffoldMessenger(child: SnackBar(content: Text('Failed to create album')));
|
||||
}
|
||||
|
||||
_buildTileIcon(UserInfo user) {
|
||||
_buildTileIcon(User user) {
|
||||
if (sharedUsersList.value.contains(user)) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
@@ -53,7 +53,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
_buildUserList(List<UserInfo> users) {
|
||||
_buildUserList(List<User> users) {
|
||||
List<Widget> usersChip = [];
|
||||
|
||||
for (var user in sharedUsersList.value) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/routing/auth_guard.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/shared/views/splash_screen.dart';
|
||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
@@ -27,8 +28,9 @@ part 'router.gr.dart';
|
||||
@MaterialAutoRouter(
|
||||
replaceInRouteName: 'Page,Route',
|
||||
routes: <AutoRoute>[
|
||||
AutoRoute(page: LoginPage, initial: true),
|
||||
AutoRoute(
|
||||
AutoRoute(page: SplashScreenPage, initial: true),
|
||||
AutoRoute(page: LoginPage),
|
||||
CustomRoute(
|
||||
page: TabControllerPage,
|
||||
guards: [AuthGuard],
|
||||
children: [
|
||||
@@ -36,6 +38,7 @@ part 'router.gr.dart';
|
||||
AutoRoute(page: SearchPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: SharingPage, guards: [AuthGuard])
|
||||
],
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
||||
|
||||
@@ -21,13 +21,21 @@ class _$AppRouter extends RootStackRouter {
|
||||
|
||||
@override
|
||||
final Map<String, PageFactory> pagesMap = {
|
||||
SplashScreenRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const SplashScreenPage());
|
||||
},
|
||||
LoginRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const LoginPage());
|
||||
},
|
||||
TabControllerRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const TabControllerPage());
|
||||
return CustomPage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const TabControllerPage(),
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
opaque: true,
|
||||
barrierDismissible: false);
|
||||
},
|
||||
ImageViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<ImageViewerRouteArgs>();
|
||||
@@ -121,7 +129,8 @@ class _$AppRouter extends RootStackRouter {
|
||||
|
||||
@override
|
||||
List<RouteConfig> get routes => [
|
||||
RouteConfig(LoginRoute.name, path: '/'),
|
||||
RouteConfig(SplashScreenRoute.name, path: '/'),
|
||||
RouteConfig(LoginRoute.name, path: '/login-page'),
|
||||
RouteConfig(TabControllerRoute.name,
|
||||
path: '/tab-controller-page',
|
||||
guards: [
|
||||
@@ -167,10 +176,18 @@ class _$AppRouter extends RootStackRouter {
|
||||
];
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SplashScreenPage]
|
||||
class SplashScreenRoute extends PageRouteInfo<void> {
|
||||
const SplashScreenRoute() : super(SplashScreenRoute.name, path: '/');
|
||||
|
||||
static const String name = 'SplashScreenRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LoginPage]
|
||||
class LoginRoute extends PageRouteInfo<void> {
|
||||
const LoginRoute() : super(LoginRoute.name, path: '/');
|
||||
const LoginRoute() : super(LoginRoute.name, path: '/login-page');
|
||||
|
||||
static const String name = 'LoginRoute';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
|
||||
class DeviceInfoRemote {
|
||||
final int id;
|
||||
@@ -66,7 +65,8 @@ class DeviceInfoRemote {
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory DeviceInfoRemote.fromJson(String source) => DeviceInfoRemote.fromMap(json.decode(source));
|
||||
factory DeviceInfoRemote.fromJson(String source) =>
|
||||
DeviceInfoRemote.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ImmichAsset {
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class ImmichAsset extends Equatable {
|
||||
final String id;
|
||||
final String deviceAssetId;
|
||||
final String userId;
|
||||
@@ -13,7 +15,7 @@ class ImmichAsset {
|
||||
final String originalPath;
|
||||
final String resizePath;
|
||||
|
||||
ImmichAsset({
|
||||
const ImmichAsset({
|
||||
required this.id,
|
||||
required this.deviceAssetId,
|
||||
required this.userId,
|
||||
@@ -56,19 +58,23 @@ class ImmichAsset {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'deviceAssetId': deviceAssetId,
|
||||
'userId': userId,
|
||||
'deviceId': deviceId,
|
||||
'type': type,
|
||||
'createdAt': createdAt,
|
||||
'modifiedAt': modifiedAt,
|
||||
'isFavorite': isFavorite,
|
||||
'duration': duration,
|
||||
'originalPath': originalPath,
|
||||
'resizePath': resizePath,
|
||||
};
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'id': id});
|
||||
result.addAll({'deviceAssetId': deviceAssetId});
|
||||
result.addAll({'userId': userId});
|
||||
result.addAll({'deviceId': deviceId});
|
||||
result.addAll({'type': type});
|
||||
result.addAll({'createdAt': createdAt});
|
||||
result.addAll({'modifiedAt': modifiedAt});
|
||||
result.addAll({'isFavorite': isFavorite});
|
||||
if (duration != null) {
|
||||
result.addAll({'duration': duration});
|
||||
}
|
||||
result.addAll({'originalPath': originalPath});
|
||||
result.addAll({'resizePath': resizePath});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory ImmichAsset.fromMap(Map<String, dynamic> map) {
|
||||
@@ -97,35 +103,7 @@ class ImmichAsset {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ImmichAsset &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.userId == userId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.type == type &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.duration == duration &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
deviceAssetId.hashCode ^
|
||||
userId.hashCode ^
|
||||
deviceId.hashCode ^
|
||||
type.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
duration.hashCode ^
|
||||
originalPath.hashCode ^
|
||||
resizePath.hashCode;
|
||||
List<Object> get props {
|
||||
return [id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class UserInfo {
|
||||
class User {
|
||||
final String id;
|
||||
final String email;
|
||||
final String createdAt;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
|
||||
UserInfo({
|
||||
User({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.createdAt,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
});
|
||||
|
||||
UserInfo copyWith({
|
||||
User copyWith({
|
||||
String? id,
|
||||
String? email,
|
||||
String? createdAt,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
}) {
|
||||
return UserInfo(
|
||||
return User(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,17 +41,19 @@ class UserInfo {
|
||||
return result;
|
||||
}
|
||||
|
||||
factory UserInfo.fromMap(Map<String, dynamic> map) {
|
||||
return UserInfo(
|
||||
factory User.fromMap(Map<String, dynamic> map) {
|
||||
return User(
|
||||
id: map['id'] ?? '',
|
||||
email: map['email'] ?? '',
|
||||
createdAt: map['createdAt'] ?? '',
|
||||
firstName: map['firstName'] ?? '',
|
||||
lastName: map['lastName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory UserInfo.fromJson(String source) => UserInfo.fromMap(json.decode(source));
|
||||
factory User.fromJson(String source) => User.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'UserInfo(id: $id, email: $email, createdAt: $createdAt)';
|
||||
@@ -52,7 +62,12 @@ class UserInfo {
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is UserInfo && other.id == id && other.email == email && other.createdAt == createdAt;
|
||||
return other is User &&
|
||||
other.id == id &&
|
||||
other.email == email &&
|
||||
other.createdAt == createdAt &&
|
||||
other.firstName == firstName &&
|
||||
other.lastName == lastName;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -33,12 +33,12 @@ class AssetNotifier extends StateNotifier<List<ImmichAsset>> {
|
||||
deleteAssets(Set<ImmichAsset> deleteAssets) async {
|
||||
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
||||
var deviceId = deviceInfo["deviceId"];
|
||||
List<String> deleteIdList = [];
|
||||
var deleteIdList = <String>[];
|
||||
// Delete asset from device
|
||||
for (var asset in deleteAssets) {
|
||||
// Delete asset on device if present
|
||||
if (asset.deviceId == deviceId) {
|
||||
AssetEntity? localAsset = await AssetEntity.fromId(asset.deviceAssetId);
|
||||
var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
|
||||
|
||||
if (localAsset != null) {
|
||||
deleteIdList.add(localAsset.id);
|
||||
@@ -46,37 +46,45 @@ class AssetNotifier extends StateNotifier<List<ImmichAsset>> {
|
||||
}
|
||||
}
|
||||
|
||||
final List<String> result = await PhotoManager.editor.deleteWithIds(deleteIdList);
|
||||
// final List<String> result = await PhotoManager.editor.deleteWithIds(deleteIdList);
|
||||
await PhotoManager.editor.deleteWithIds(deleteIdList);
|
||||
|
||||
// Delete asset on server
|
||||
List<DeleteAssetResponse>? deleteAssetResult = await _assetService.deleteAssets(deleteAssets);
|
||||
List<DeleteAssetResponse>? deleteAssetResult =
|
||||
await _assetService.deleteAssets(deleteAssets);
|
||||
if (deleteAssetResult == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (var asset in deleteAssetResult) {
|
||||
if (asset.status == 'success') {
|
||||
state = state.where((immichAsset) => immichAsset.id != asset.id).toList();
|
||||
state =
|
||||
state.where((immichAsset) => immichAsset.id != asset.id).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAsset>>((ref) {
|
||||
final assetProvider =
|
||||
StateNotifierProvider<AssetNotifier, List<ImmichAsset>>((ref) {
|
||||
return AssetNotifier(ref);
|
||||
});
|
||||
|
||||
final assetGroupByDateTimeProvider = StateProvider((ref) {
|
||||
var assets = ref.watch(assetProvider);
|
||||
|
||||
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
|
||||
assets.sortByCompare<DateTime>(
|
||||
(e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||
return assets.groupListsBy((element) =>
|
||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
|
||||
});
|
||||
|
||||
final assetGroupByMonthYearProvider = StateProvider((ref) {
|
||||
var assets = ref.watch(assetProvider);
|
||||
|
||||
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||
assets.sortByCompare<DateTime>(
|
||||
(e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||
|
||||
return assets.groupListsBy((element) => DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)));
|
||||
return assets.groupListsBy((element) =>
|
||||
DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)));
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
|
||||
try {
|
||||
String? localReleaseVersion = box.get(githubReleaseInfoKey);
|
||||
|
||||
Response res = await dio.get(
|
||||
var res = await dio.get(
|
||||
"https://api.github.com/repos/alextran1502/immich/releases/latest",
|
||||
options: Options(
|
||||
headers: {"Accept": "application/vnd.github.v3+json"},
|
||||
@@ -34,7 +34,8 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestTagVersion.isNotEmpty && localReleaseVersion != latestTagVersion) {
|
||||
if (latestTagVersion.isNotEmpty &&
|
||||
localReleaseVersion != latestTagVersion) {
|
||||
VersionAnnouncementOverlayController.appLoader.show();
|
||||
return;
|
||||
}
|
||||
@@ -54,4 +55,5 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
|
||||
}
|
||||
}
|
||||
|
||||
final releaseInfoProvider = StateNotifierProvider<ReleaseInfoNotifier, String>((ref) => ReleaseInfoNotifier());
|
||||
final releaseInfoProvider = StateNotifierProvider<ReleaseInfoNotifier, String>(
|
||||
(ref) => ReleaseInfoNotifier());
|
||||
|
||||
@@ -11,7 +11,8 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
: super(
|
||||
ServerInfoState(
|
||||
mapboxInfo: MapboxInfo(isEnable: false, mapboxSecret: ""),
|
||||
serverVersion: ServerVersion(major: 0, patch: 0, minor: 0, build: 0),
|
||||
serverVersion:
|
||||
ServerVersion(major: 0, patch: 0, minor: 0, build: 0),
|
||||
isVersionMismatch: false,
|
||||
versionMismatchErrorMessage: "",
|
||||
),
|
||||
@@ -33,7 +34,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
|
||||
state = state.copyWith(serverVersion: serverVersion);
|
||||
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
var packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
Map<String, int> appVersion = _getDetailVersion(packageInfo.version);
|
||||
|
||||
@@ -57,7 +58,8 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isVersionMismatch: false, versionMismatchErrorMessage: "");
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: false, versionMismatchErrorMessage: "");
|
||||
}
|
||||
|
||||
Map<String, int> _getDetailVersion(String version) {
|
||||
@@ -75,6 +77,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
}
|
||||
}
|
||||
|
||||
final serverInfoProvider = StateNotifierProvider<ServerInfoNotifier, ServerInfoState>((ref) {
|
||||
final serverInfoProvider =
|
||||
StateNotifierProvider<ServerInfoNotifier, ServerInfoState>((ref) {
|
||||
return ServerInfoNotifier();
|
||||
});
|
||||
|
||||
@@ -3,12 +3,11 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart';
|
||||
|
||||
class WebscoketState {
|
||||
final Socket? socket;
|
||||
@@ -60,8 +59,9 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
||||
debugPrint("[WEBSOCKET] Attempting to connect to ws");
|
||||
// Configure socket transports must be sepecified
|
||||
Socket socket = io(
|
||||
endpoint,
|
||||
endpoint.toString().replaceAll('/api', ''),
|
||||
OptionBuilder()
|
||||
.setPath('/api/socket.io')
|
||||
.setTransports(['websocket'])
|
||||
.enableReconnection()
|
||||
.enableForceNew()
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'dart:io' show Platform;
|
||||
class DeviceInfoService {
|
||||
Future<Map<String, dynamic>> getDeviceInfo() async {
|
||||
// Get device info
|
||||
String deviceId = await FlutterUdid.consistentUdid;
|
||||
String deviceType = "";
|
||||
var deviceId = await FlutterUdid.consistentUdid;
|
||||
var deviceType = "";
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
deviceType = "ANDROID";
|
||||
|
||||
@@ -4,15 +4,22 @@ import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
|
||||
import 'package:immich_mobile/utils/files_helper.dart';
|
||||
|
||||
class NetworkService {
|
||||
late final Dio dio;
|
||||
|
||||
NetworkService() {
|
||||
dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
}
|
||||
|
||||
Future<dynamic> deleteRequest({required String url, dynamic data}) async {
|
||||
try {
|
||||
var dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
Response res = await dio.delete('$savedEndpoint/$url', data: data);
|
||||
|
||||
@@ -22,15 +29,15 @@ class NetworkService {
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError: ${e.response}");
|
||||
} catch (e) {
|
||||
debugPrint("ERROR getRequest: ${e.toString()}");
|
||||
debugPrint("ERROR deleteRequest: ${e.toString()}");
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> getRequest({required String url, bool isByteResponse = false, bool isStreamReponse = false}) async {
|
||||
Future<dynamic> getRequest(
|
||||
{required String url,
|
||||
bool isByteResponse = false,
|
||||
bool isStreamReponse = false}) async {
|
||||
try {
|
||||
var dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
|
||||
if (isByteResponse) {
|
||||
@@ -66,63 +73,66 @@ class NetworkService {
|
||||
|
||||
Future<dynamic> postRequest({required String url, dynamic data}) async {
|
||||
try {
|
||||
var dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
String validUrl = Uri.parse('$savedEndpoint/$url').toString();
|
||||
Response res = await dio.post(validUrl, data: data);
|
||||
var validUrl = Uri.parse('$savedEndpoint/$url').toString();
|
||||
var res = await dio.post(validUrl, data: data);
|
||||
|
||||
return res;
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError: ${e.response}");
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR BackupService: $e");
|
||||
debugPrint("ERROR PostRequest: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> putRequest({required String url, dynamic data}) async {
|
||||
try {
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
var validUrl = Uri.parse('$savedEndpoint/$url').toString();
|
||||
var res = await dio.put(validUrl, data: data);
|
||||
|
||||
return res;
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError: ${e.response}");
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR PutRequest: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> patchRequest({required String url, dynamic data}) async {
|
||||
try {
|
||||
var dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
|
||||
String validUrl = Uri.parse('$savedEndpoint/$url').toString();
|
||||
Response res = await dio.patch(validUrl, data: data);
|
||||
var validUrl = Uri.parse('$savedEndpoint/$url').toString();
|
||||
var res = await dio.patch(validUrl, data: data);
|
||||
|
||||
return res;
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError: ${e.response}");
|
||||
} catch (e) {
|
||||
debugPrint("ERROR BackupService: $e");
|
||||
debugPrint("ERROR PatchRequest: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> pingServer() async {
|
||||
try {
|
||||
var dio = Dio();
|
||||
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
var validUrl = Uri.parse('$savedEndpoint/server-info/ping').toString();
|
||||
|
||||
String validUrl = Uri.parse('$savedEndpoint/server-info/ping').toString();
|
||||
debugPrint("ping server at url $validUrl");
|
||||
|
||||
debugPrint("pint server at url $validUrl");
|
||||
Response res = await dio.get(validUrl);
|
||||
var res = await dio.get(validUrl);
|
||||
var jsonRespsonse = jsonDecode(res.toString());
|
||||
|
||||
if (jsonRespsonse["res"] == "pong") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return jsonRespsonse["res"] == "pong";
|
||||
} on DioError catch (e) {
|
||||
debugPrint("[PING SERVER] DioError: ${e.response} - $e");
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR BackupService: $e");
|
||||
debugPrint("ERROR PingServer: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/models/mapbox_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_version.model.dart';
|
||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||
|
||||
class ServerInfoService {
|
||||
final NetworkService _networkService = NetworkService();
|
||||
|
||||
@@ -3,23 +3,23 @@ import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/upload_profile_image_repsonse.model.dart';
|
||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/user.model.dart';
|
||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
|
||||
import 'package:immich_mobile/utils/files_helper.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
class UserService {
|
||||
final NetworkService _networkService = NetworkService();
|
||||
|
||||
Future<List<UserInfo>> getAllUsersInfo() async {
|
||||
Future<List<User>> getAllUsersInfo() async {
|
||||
try {
|
||||
Response res = await _networkService.getRequest(url: 'user');
|
||||
var res = await _networkService.getRequest(url: 'user');
|
||||
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||
List<UserInfo> result = List.from(decodedData.map((e) => UserInfo.fromMap(e)));
|
||||
List<User> result = List.from(decodedData.map((e) => User.fromMap(e)));
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,12 +10,10 @@ class ImmichToast {
|
||||
ToastType toastType = ToastType.info,
|
||||
ToastGravity gravity = ToastGravity.TOP,
|
||||
}) {
|
||||
FToast fToast;
|
||||
|
||||
fToast = FToast();
|
||||
final fToast = FToast();
|
||||
fToast.init(context);
|
||||
|
||||
_getColor(ToastType type, BuildContext context) {
|
||||
Color _getColor(ToastType type, BuildContext context) {
|
||||
switch (type) {
|
||||
case ToastType.info:
|
||||
return Theme.of(context).primaryColor;
|
||||
@@ -26,6 +24,26 @@ class ImmichToast {
|
||||
}
|
||||
}
|
||||
|
||||
Icon _getIcon(ToastType type) {
|
||||
switch (type) {
|
||||
case ToastType.info:
|
||||
return Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
case ToastType.success:
|
||||
return const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Color.fromARGB(255, 78, 140, 124),
|
||||
);
|
||||
case ToastType.error:
|
||||
return const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: Color.fromARGB(255, 240, 162, 156),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fToast.showToast(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
@@ -40,24 +58,7 @@ class ImmichToast {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
(toastType == ToastType.info)
|
||||
? Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
)
|
||||
: Container(),
|
||||
(toastType == ToastType.success)
|
||||
? const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Color.fromARGB(255, 78, 140, 124),
|
||||
)
|
||||
: Container(),
|
||||
(toastType == ToastType.error)
|
||||
? const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: Color.fromARGB(255, 240, 162, 156),
|
||||
)
|
||||
: Container(),
|
||||
_getIcon(toastType),
|
||||
const SizedBox(
|
||||
width: 12.0,
|
||||
),
|
||||
|
||||
@@ -9,25 +9,25 @@ class ImmichLoadingOverlay extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: ImmichLoadingOverlayController.appLoader.loaderShowingNotifier,
|
||||
valueListenable:
|
||||
ImmichLoadingOverlayController.appLoader.loaderShowingNotifier,
|
||||
builder: (context, shouldShow, child) {
|
||||
if (shouldShow) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black54,
|
||||
body: Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
return shouldShow
|
||||
? const Scaffold(
|
||||
backgroundColor: Colors.black54,
|
||||
body: Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
)
|
||||
: const SizedBox();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImmichLoadingOverlayController {
|
||||
static final ImmichLoadingOverlayController appLoader = ImmichLoadingOverlayController();
|
||||
static final ImmichLoadingOverlayController appLoader =
|
||||
ImmichLoadingOverlayController();
|
||||
ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false);
|
||||
ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message');
|
||||
|
||||
|
||||
72
mobile/lib/shared/views/splash_screen.dart
Normal file
72
mobile/lib/shared/views/splash_screen.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SplashScreenPage extends HookConsumerWidget {
|
||||
const SplashScreenPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
HiveSavedLoginInfo? loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
|
||||
|
||||
void performLoggingIn() async {
|
||||
var isAuthenticated = await ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.login(loginInfo!.email, loginInfo.password, loginInfo.serverUrl, true);
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Resume backup (if enable) then navigate
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
||||
} else {
|
||||
AutoRouter.of(context).push(const LoginRoute());
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
if (loginInfo != null && loginInfo.isSaveLogin) {
|
||||
performLoggingIn();
|
||||
} else {
|
||||
AutoRouter.of(context).push(const LoginRoute());
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: immichBackgroundColor,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Image(
|
||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
width: 200,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'IMMICH',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 48,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
void goToReleaseNote() async {
|
||||
final Uri _url = Uri.parse('https://github.com/alextran1502/immich/releases/latest');
|
||||
await launchUrl(_url);
|
||||
final Uri url =
|
||||
Uri.parse('https://github.com/alextran1502/immich/releases/latest');
|
||||
await launchUrl(url);
|
||||
}
|
||||
|
||||
void onAcknowledgeTapped() {
|
||||
@@ -21,7 +22,8 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: VersionAnnouncementOverlayController.appLoader.loaderShowingNotifier,
|
||||
valueListenable:
|
||||
VersionAnnouncementOverlayController.appLoader.loaderShowingNotifier,
|
||||
builder: (context, shouldShow, child) {
|
||||
if (shouldShow) {
|
||||
return Scaffold(
|
||||
@@ -51,10 +53,14 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 14, fontFamily: 'WorkSans', color: Colors.black87, height: 1.2),
|
||||
fontSize: 14,
|
||||
fontFamily: 'WorkSans',
|
||||
color: Colors.black87,
|
||||
height: 1.2),
|
||||
children: <TextSpan>[
|
||||
const TextSpan(
|
||||
text: 'Hi friend, there is a new release of',
|
||||
text:
|
||||
'Hi friend, there is a new release of',
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' Immich ',
|
||||
@@ -65,19 +71,21 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: "please take your time to visit the ",
|
||||
text:
|
||||
"please take your time to visit the ",
|
||||
),
|
||||
TextSpan(
|
||||
text: "release note",
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()..onTap = goToReleaseNote,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = goToReleaseNote,
|
||||
),
|
||||
const TextSpan(
|
||||
text:
|
||||
" and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -85,22 +93,24 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const StadiumBorder(),
|
||||
visualDensity: VisualDensity.standard,
|
||||
primary: Colors.indigo,
|
||||
onPrimary: Colors.grey[50],
|
||||
elevation: 2,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const StadiumBorder(),
|
||||
visualDensity: VisualDensity.standard,
|
||||
primary: Colors.indigo,
|
||||
onPrimary: Colors.grey[50],
|
||||
elevation: 2,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10, horizontal: 25),
|
||||
),
|
||||
onPressed: onAcknowledgeTapped,
|
||||
child: const Text(
|
||||
"Acknowledge",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
onPressed: onAcknowledgeTapped,
|
||||
child: const Text(
|
||||
"Acknowledge",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
)),
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -119,7 +129,8 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
class VersionAnnouncementOverlayController {
|
||||
static final VersionAnnouncementOverlayController appLoader = VersionAnnouncementOverlayController();
|
||||
static final VersionAnnouncementOverlayController appLoader =
|
||||
VersionAnnouncementOverlayController();
|
||||
ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false);
|
||||
ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message');
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
|
||||
|
||||
@@ -141,6 +141,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
cancellation_token:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cancellation_token
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
cancellation_token_http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cancellation_token_http
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -184,7 +198,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: collection
|
||||
url: "https://pub.dartlang.org"
|
||||
@@ -219,7 +233,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.17.1"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cupertino_icons
|
||||
url: "https://pub.dartlang.org"
|
||||
@@ -320,7 +334,7 @@ packages:
|
||||
name: flutter_lints
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
version: "2.0.1"
|
||||
flutter_map:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -437,7 +451,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.15.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
url: "https://pub.dartlang.org"
|
||||
@@ -451,12 +465,12 @@ packages:
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http_parser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "4.0.1"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -528,7 +542,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.5.0"
|
||||
latlong2:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: latlong2
|
||||
url: "https://pub.dartlang.org"
|
||||
@@ -540,7 +554,7 @@ packages:
|
||||
name: lints
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "2.0.0"
|
||||
lists:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -654,19 +668,19 @@ packages:
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
path:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.8.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.10"
|
||||
version: "2.0.11"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -743,7 +757,7 @@ packages:
|
||||
name: photo_view
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.14.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -847,13 +861,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.99"
|
||||
sliver_tools:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sliver_tools
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.6"
|
||||
socket_io_client:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1127,13 +1134,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.10"
|
||||
visibility_detector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: visibility_detector
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
wakelock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -2,15 +2,15 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.11.0+17
|
||||
version: 1.13.0+20
|
||||
|
||||
environment:
|
||||
sdk: ">=2.15.1 <3.0.0"
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.2
|
||||
|
||||
photo_manager: ^2.0.6
|
||||
flutter_hooks: ^0.18.0
|
||||
hooks_riverpod: ^2.0.0-dev.0
|
||||
@@ -23,14 +23,12 @@ dependencies:
|
||||
auto_route: ^4.0.1
|
||||
exif: ^3.1.1
|
||||
transparent_image: ^2.0.0
|
||||
visibility_detector: ^0.2.2
|
||||
flutter_launcher_icons: "^0.9.2"
|
||||
fluttertoast: ^8.0.8
|
||||
video_player: ^2.2.18
|
||||
chewie: ^1.2.2
|
||||
sliver_tools: ^0.2.5
|
||||
badges: ^2.0.2
|
||||
photo_view: ^0.13.0
|
||||
photo_view: ^0.14.0
|
||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||
flutter_map: ^0.14.0
|
||||
flutter_udid: ^2.0.0
|
||||
@@ -40,11 +38,19 @@ dependencies:
|
||||
equatable: ^2.0.3
|
||||
image_picker: ^0.8.5+3
|
||||
url_launcher: ^6.1.3
|
||||
http: 0.13.4
|
||||
cancellation_token_http: ^1.1.0
|
||||
|
||||
path: ^1.8.1
|
||||
path_provider: ^2.0.11
|
||||
latlong2: ^0.8.1
|
||||
collection: ^1.16.0
|
||||
http_parser: ^4.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^1.0.0
|
||||
flutter_lints: ^2.0.1
|
||||
hive_generator: ^1.1.2
|
||||
build_runner: ^2.1.7
|
||||
auto_route_generator: ^4.0.0
|
||||
|
||||
228
server/apps/immich/src/api-v1/album/album-repository.ts
Normal file
228
server/apps/immich/src/api-v1/album/album-repository.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { getConnection, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
|
||||
export interface IAlbumRepository {
|
||||
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
|
||||
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>;
|
||||
get(albumId: string): Promise<AlbumEntity>;
|
||||
delete(album: AlbumEntity): Promise<void>;
|
||||
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
|
||||
removeUser(album: AlbumEntity, userId: string): Promise<void>;
|
||||
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<boolean>;
|
||||
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
|
||||
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
||||
}
|
||||
|
||||
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumRepository implements IAlbumRepository {
|
||||
constructor(
|
||||
@InjectRepository(AlbumEntity)
|
||||
private albumRepository: Repository<AlbumEntity>,
|
||||
|
||||
@InjectRepository(AssetAlbumEntity)
|
||||
private assetAlbumRepository: Repository<AssetAlbumEntity>,
|
||||
|
||||
@InjectRepository(UserAlbumEntity)
|
||||
private userAlbumRepository: Repository<UserAlbumEntity>,
|
||||
) {}
|
||||
|
||||
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
|
||||
return await getConnection().transaction(async (transactionalEntityManager) => {
|
||||
// Create album entity
|
||||
const newAlbum = new AlbumEntity();
|
||||
newAlbum.ownerId = ownerId;
|
||||
newAlbum.albumName = createAlbumDto.albumName;
|
||||
|
||||
const album = await transactionalEntityManager.save(newAlbum);
|
||||
|
||||
// Add shared users
|
||||
if (createAlbumDto.sharedWithUserIds?.length) {
|
||||
for (const sharedUserId of createAlbumDto.sharedWithUserIds) {
|
||||
const newSharedUser = new UserAlbumEntity();
|
||||
newSharedUser.albumId = album.id;
|
||||
newSharedUser.sharedUserId = sharedUserId;
|
||||
|
||||
await transactionalEntityManager.save(newSharedUser);
|
||||
}
|
||||
}
|
||||
|
||||
// Add shared assets
|
||||
const newRecords: AssetAlbumEntity[] = [];
|
||||
|
||||
if (createAlbumDto.assetIds?.length) {
|
||||
for (const assetId of createAlbumDto.assetIds) {
|
||||
const newAssetAlbum = new AssetAlbumEntity();
|
||||
newAssetAlbum.assetId = assetId;
|
||||
newAssetAlbum.albumId = album.id;
|
||||
|
||||
newRecords.push(newAssetAlbum);
|
||||
}
|
||||
}
|
||||
|
||||
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
||||
album.albumThumbnailAssetId = newRecords[0].assetId;
|
||||
await transactionalEntityManager.save(album);
|
||||
}
|
||||
|
||||
await transactionalEntityManager.save([...newRecords]);
|
||||
|
||||
return album;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
|
||||
const filteringByShared = typeof getAlbumsDto.shared == 'boolean';
|
||||
const userId = ownerId;
|
||||
let query = this.albumRepository.createQueryBuilder('album');
|
||||
|
||||
const getSharedAlbumIdsSubQuery = (qb: SelectQueryBuilder<AlbumEntity>) => {
|
||||
return qb
|
||||
.subQuery()
|
||||
.select('albumSub.id')
|
||||
.from(AlbumEntity, 'albumSub')
|
||||
.innerJoin('albumSub.sharedUsers', 'userAlbumSub')
|
||||
.where('albumSub.ownerId = :ownerId', { ownerId: userId })
|
||||
.getQuery();
|
||||
};
|
||||
|
||||
if (filteringByShared) {
|
||||
if (getAlbumsDto.shared) {
|
||||
// shared albums
|
||||
query = query
|
||||
.innerJoinAndSelect('album.sharedUsers', 'sharedUser')
|
||||
.innerJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
||||
.where((qb) => {
|
||||
// owned and shared with other users
|
||||
const subQuery = getSharedAlbumIdsSubQuery(qb);
|
||||
return `album.id IN ${subQuery}`;
|
||||
})
|
||||
.orWhere((qb) => {
|
||||
// shared with userId
|
||||
const subQuery = qb
|
||||
.subQuery()
|
||||
.select('userAlbum.albumId')
|
||||
.from(UserAlbumEntity, 'userAlbum')
|
||||
.where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
|
||||
.getQuery();
|
||||
return `album.id IN ${subQuery}`;
|
||||
});
|
||||
} else {
|
||||
// owned, not shared albums
|
||||
query = query.where('album.ownerId = :ownerId', { ownerId: userId }).andWhere((qb) => {
|
||||
const subQuery = getSharedAlbumIdsSubQuery(qb);
|
||||
return `album.id NOT IN ${subQuery}`;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// owned and shared with userId
|
||||
query = query
|
||||
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
|
||||
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
||||
.where('album.ownerId = :ownerId', { ownerId: userId })
|
||||
.orWhere((qb) => {
|
||||
const subQuery = qb
|
||||
.subQuery()
|
||||
.select('userAlbum.albumId')
|
||||
.from(UserAlbumEntity, 'userAlbum')
|
||||
.where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
|
||||
.getQuery();
|
||||
return `album.id IN ${subQuery}`;
|
||||
});
|
||||
}
|
||||
return query.orderBy('album.createdAt', 'DESC').getMany();
|
||||
}
|
||||
|
||||
async get(albumId: string): Promise<AlbumEntity | undefined> {
|
||||
const album = await this.albumRepository.findOne({
|
||||
where: { id: albumId },
|
||||
relations: ['sharedUsers', 'sharedUsers.userInfo', 'assets', 'assets.assetInfo'],
|
||||
});
|
||||
|
||||
if (!album) {
|
||||
return;
|
||||
}
|
||||
// TODO: sort in query
|
||||
const sortedSharedAsset = album.assets.sort(
|
||||
(a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
|
||||
);
|
||||
|
||||
album.assets = sortedSharedAsset;
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
async delete(album: AlbumEntity): Promise<void> {
|
||||
await this.albumRepository.delete({ id: album.id, ownerId: album.ownerId });
|
||||
}
|
||||
|
||||
async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity> {
|
||||
const newRecords: UserAlbumEntity[] = [];
|
||||
|
||||
for (const sharedUserId of addUsersDto.sharedUserIds) {
|
||||
const newEntity = new UserAlbumEntity();
|
||||
newEntity.albumId = album.id;
|
||||
newEntity.sharedUserId = sharedUserId;
|
||||
|
||||
newRecords.push(newEntity);
|
||||
}
|
||||
|
||||
await this.userAlbumRepository.save([...newRecords]);
|
||||
return this.get(album.id);
|
||||
}
|
||||
|
||||
async removeUser(album: AlbumEntity, userId: string): Promise<void> {
|
||||
await this.userAlbumRepository.delete({ albumId: album.id, sharedUserId: userId });
|
||||
}
|
||||
|
||||
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<boolean> {
|
||||
let deleteAssetCount = 0;
|
||||
// TODO: should probably do a single delete query?
|
||||
for (const assetId of removeAssetsDto.assetIds) {
|
||||
const res = await this.assetAlbumRepository.delete({ albumId: album.id, assetId: assetId });
|
||||
if (res.affected == 1) deleteAssetCount++;
|
||||
}
|
||||
|
||||
// TODO: No need to return boolean if using a singe delete query
|
||||
return deleteAssetCount == removeAssetsDto.assetIds.length;
|
||||
}
|
||||
|
||||
async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity> {
|
||||
const newRecords: AssetAlbumEntity[] = [];
|
||||
|
||||
for (const assetId of addAssetsDto.assetIds) {
|
||||
const newAssetAlbum = new AssetAlbumEntity();
|
||||
newAssetAlbum.assetId = assetId;
|
||||
newAssetAlbum.albumId = album.id;
|
||||
|
||||
newRecords.push(newAssetAlbum);
|
||||
}
|
||||
|
||||
// Add album thumbnail if not exist.
|
||||
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
||||
album.albumThumbnailAssetId = newRecords[0].assetId;
|
||||
await this.albumRepository.save(album);
|
||||
}
|
||||
|
||||
await this.assetAlbumRepository.save([...newRecords]);
|
||||
return this.get(album.id);
|
||||
}
|
||||
|
||||
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity> {
|
||||
album.albumName = updateAlbumDto.albumName;
|
||||
|
||||
return this.albumRepository.save(album);
|
||||
}
|
||||
}
|
||||
105
server/apps/immich/src/api-v1/album/album.controller.ts
Normal file
105
server/apps/immich/src/api-v1/album/album.controller.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
ValidationPipe,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
|
||||
import { AlbumService } from './album.service';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
|
||||
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('album')
|
||||
export class AlbumController {
|
||||
constructor(private readonly albumService: AlbumService) {}
|
||||
|
||||
@Post()
|
||||
async create(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
|
||||
return this.albumService.create(authUser, createAlbumDto);
|
||||
}
|
||||
|
||||
@Put('/:albumId/users')
|
||||
async addUsers(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) addUsersDto: AddUsersDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId);
|
||||
}
|
||||
|
||||
@Put('/:albumId/assets')
|
||||
async addAssets(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) addAssetsDto: AddAssetsDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getAllAlbums(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto,
|
||||
) {
|
||||
return this.albumService.getAllAlbums(authUser, query);
|
||||
}
|
||||
|
||||
@Get('/:albumId')
|
||||
async getAlbumInfo(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.getAlbumInfo(authUser, albumId);
|
||||
}
|
||||
|
||||
@Delete('/:albumId/assets')
|
||||
async removeAssetFromAlbum(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
|
||||
}
|
||||
|
||||
@Delete('/:albumId')
|
||||
async deleteAlbum(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.deleteAlbum(authUser, albumId);
|
||||
}
|
||||
|
||||
@Delete('/:albumId/user/:userId')
|
||||
async removeUserFromAlbum(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
|
||||
) {
|
||||
return this.albumService.removeUserFromAlbum(authUser, albumId, userId);
|
||||
}
|
||||
|
||||
@Patch('/:albumId')
|
||||
async updateAlbumInfo(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) updateAlbumInfoDto: UpdateAlbumDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.updateAlbumTitle(authUser, updateAlbumInfoDto, albumId);
|
||||
}
|
||||
}
|
||||
23
server/apps/immich/src/api-v1/album/album.module.ts
Normal file
23
server/apps/immich/src/api-v1/album/album.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AlbumService } from './album.service';
|
||||
import { AlbumController } from './album.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
|
||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
|
||||
controllers: [AlbumController],
|
||||
providers: [
|
||||
AlbumService,
|
||||
{
|
||||
provide: ALBUM_REPOSITORY,
|
||||
useClass: AlbumRepository,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AlbumModule {}
|
||||
414
server/apps/immich/src/api-v1/album/album.service.spec.ts
Normal file
414
server/apps/immich/src/api-v1/album/album.service.spec.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { AlbumService } from './album.service';
|
||||
import { IAlbumRepository } from './album-repository';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||
|
||||
describe('Album service', () => {
|
||||
let sut: AlbumService;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: '1111',
|
||||
email: 'auth@test.com',
|
||||
});
|
||||
const albumId = '0001';
|
||||
const sharedAlbumOwnerId = '2222';
|
||||
const sharedAlbumSharedAlsoWithId = '3333';
|
||||
const ownedAlbumSharedWithId = '4444';
|
||||
|
||||
const _getOwnedAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = authUser.id;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.sharedUsers = [];
|
||||
albumEntity.assets = [];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
const _getOwnedSharedAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = authUser.id;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.assets = [];
|
||||
albumEntity.sharedUsers = [
|
||||
{
|
||||
id: '99',
|
||||
albumId,
|
||||
sharedUserId: ownedAlbumSharedWithId,
|
||||
//@ts-expect-error Partial stub
|
||||
albumInfo: {},
|
||||
//@ts-expect-error Partial stub
|
||||
userInfo: {
|
||||
id: ownedAlbumSharedWithId,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
const _getSharedWithAuthUserAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = sharedAlbumOwnerId;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.assets = [];
|
||||
albumEntity.sharedUsers = [
|
||||
{
|
||||
id: '99',
|
||||
albumId,
|
||||
sharedUserId: authUser.id,
|
||||
//@ts-expect-error Partial stub
|
||||
albumInfo: {},
|
||||
//@ts-expect-error Partial stub
|
||||
userInfo: {
|
||||
id: authUser.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '98',
|
||||
albumId,
|
||||
sharedUserId: sharedAlbumSharedAlsoWithId,
|
||||
//@ts-expect-error Partial stub
|
||||
albumInfo: {},
|
||||
//@ts-expect-error Partial stub
|
||||
userInfo: {
|
||||
id: sharedAlbumSharedAlsoWithId,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
const _getNotOwnedNotSharedAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = '5555';
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.sharedUsers = [];
|
||||
albumEntity.assets = [];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
albumRepositoryMock = {
|
||||
addAssets: jest.fn(),
|
||||
addSharedUsers: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
removeAssets: jest.fn(),
|
||||
removeUser: jest.fn(),
|
||||
updateAlbum: jest.fn(),
|
||||
};
|
||||
sut = new AlbumService(albumRepositoryMock);
|
||||
});
|
||||
|
||||
it('creates album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.create.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.create(authUser, {
|
||||
albumName: albumEntity.albumName,
|
||||
});
|
||||
|
||||
expect(result.id).toEqual(albumEntity.id);
|
||||
expect(result.albumName).toEqual(albumEntity.albumName);
|
||||
});
|
||||
|
||||
it('gets list of albums for auth user', async () => {
|
||||
const ownedAlbum = _getOwnedAlbum();
|
||||
const ownedSharedAlbum = _getOwnedSharedAlbum();
|
||||
const sharedWithMeAlbum = _getSharedWithAuthUserAlbum();
|
||||
const albums: AlbumEntity[] = [ownedAlbum, ownedSharedAlbum, sharedWithMeAlbum];
|
||||
|
||||
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums));
|
||||
|
||||
const result = await sut.getAllAlbums(authUser, {});
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].id).toEqual(ownedAlbum.id);
|
||||
expect(result[1].id).toEqual(ownedSharedAlbum.id);
|
||||
expect(result[2].id).toEqual(sharedWithMeAlbum.id);
|
||||
});
|
||||
|
||||
it('gets an owned album', async () => {
|
||||
const ownerId = authUser.id;
|
||||
const albumId = '0001';
|
||||
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const expectedResult: AlbumResponseDto = {
|
||||
albumName: 'name',
|
||||
albumThumbnailAssetId: undefined,
|
||||
createdAt: 'date',
|
||||
id: '0001',
|
||||
ownerId,
|
||||
shared: false,
|
||||
assets: [],
|
||||
sharedUsers: [],
|
||||
};
|
||||
await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('gets a shared album', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.getAlbumInfo(authUser, albumId);
|
||||
expect(result.id).toEqual(albumId);
|
||||
expect(result.ownerId).toEqual(sharedAlbumOwnerId);
|
||||
expect(result.shared).toEqual(true);
|
||||
expect(result.sharedUsers).toHaveLength(2);
|
||||
expect(result.sharedUsers[0].id).toEqual(authUser.id);
|
||||
expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId);
|
||||
});
|
||||
|
||||
it('prevents retrieving an album that is not owned or shared', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
await expect(sut.getAlbumInfo(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws a not found exception if the album is not found', async () => {
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve(undefined));
|
||||
await expect(sut.getAlbumInfo(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes an owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.delete.mockImplementation(() => Promise.resolve());
|
||||
await sut.deleteAlbum(authUser, albumId);
|
||||
expect(albumRepositoryMock.delete).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.delete).toHaveBeenCalledWith(albumEntity);
|
||||
});
|
||||
|
||||
it('prevents deleting a shared album (shared with auth user)', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
await expect(sut.deleteAlbum(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('removes a shared user from an owned album', async () => {
|
||||
const albumEntity = _getOwnedSharedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
|
||||
await expect(sut.removeUserFromAlbum(authUser, albumEntity.id, ownedAlbumSharedWithId)).resolves.toBeUndefined();
|
||||
expect(albumRepositoryMock.removeUser).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, ownedAlbumSharedWithId);
|
||||
});
|
||||
|
||||
it('prevents removing a shared user from a not owned album (shared with auth user)', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
const userIdToRemove = sharedAlbumSharedAlsoWithId;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
await expect(sut.removeUserFromAlbum(authUser, albumId, userIdToRemove)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(albumRepositoryMock.removeUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes itself from a shared album', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
|
||||
|
||||
await sut.removeUserFromAlbum(authUser, albumEntity.id, authUser.id);
|
||||
expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
|
||||
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
|
||||
});
|
||||
|
||||
it('removes itself from a shared album using "me" as id', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
|
||||
|
||||
await sut.removeUserFromAlbum(authUser, albumEntity.id, 'me');
|
||||
expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
|
||||
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
|
||||
});
|
||||
|
||||
it('prevents removing itself from a owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
await expect(sut.removeUserFromAlbum(authUser, albumEntity.id, authUser.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates a owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
const updatedAlbumName = 'new album name';
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.updateAlbum.mockImplementation(() =>
|
||||
Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
|
||||
);
|
||||
|
||||
const result = await sut.updateAlbumTitle(
|
||||
authUser,
|
||||
{
|
||||
albumName: updatedAlbumName,
|
||||
ownerId: 'this is not used and will be removed',
|
||||
},
|
||||
albumId,
|
||||
);
|
||||
|
||||
expect(result.id).toEqual(albumId);
|
||||
expect(result.albumName).toEqual(updatedAlbumName);
|
||||
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
|
||||
albumName: updatedAlbumName,
|
||||
ownerId: 'this is not used and will be removed',
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents updating a not owned album (shared with auth user)', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
await expect(
|
||||
sut.updateAlbumTitle(
|
||||
authUser,
|
||||
{
|
||||
albumName: 'new album name',
|
||||
ownerId: 'this is not used and will be removed',
|
||||
},
|
||||
albumId,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('adds assets to owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.addAssetsToAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumId,
|
||||
);
|
||||
|
||||
// TODO: stub and expect album rendered
|
||||
expect(result.id).toEqual(albumId);
|
||||
});
|
||||
|
||||
it('adds assets to shared album (shared with auth user)', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.addAssetsToAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumId,
|
||||
);
|
||||
|
||||
// TODO: stub and expect album rendered
|
||||
expect(result.id).toEqual(albumId);
|
||||
});
|
||||
|
||||
it('prevents adding assets to a not owned / shared album', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
expect(
|
||||
sut.addAssetsToAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumId,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('removes assets from owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
|
||||
|
||||
await expect(
|
||||
sut.removeAssetsFromAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumEntity.id,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
assetIds: ['1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('removes assets from shared album (shared with auth user)', async () => {
|
||||
const albumEntity = _getOwnedSharedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
|
||||
|
||||
await expect(
|
||||
sut.removeAssetsFromAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumEntity.id,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
assetIds: ['1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents removing assets from a not owned / shared album', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
expect(
|
||||
sut.removeAssetsFromAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumId,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
113
server/apps/immich/src/api-v1/album/album.service.ts
Normal file
113
server/apps/immich/src/api-v1/album/album.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
import { AlbumResponseDto, mapAlbum } from './response-dto/album-response.dto';
|
||||
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumService {
|
||||
constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {}
|
||||
|
||||
private async _getAlbum({
|
||||
authUser,
|
||||
albumId,
|
||||
validateIsOwner = true,
|
||||
}: {
|
||||
authUser: AuthUserDto;
|
||||
albumId: string;
|
||||
validateIsOwner?: boolean;
|
||||
}): Promise<AlbumEntity> {
|
||||
const album = await this._albumRepository.get(albumId);
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album Not Found');
|
||||
}
|
||||
const isOwner = album.ownerId == authUser.id;
|
||||
|
||||
if (validateIsOwner && !isOwner) {
|
||||
throw new ForbiddenException('Unauthorized Album Access');
|
||||
} else if (!isOwner && !album.sharedUsers.some((user) => user.sharedUserId == authUser.id)) {
|
||||
throw new ForbiddenException('Unauthorized Album Access');
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||
const albumEntity = await this._albumRepository.create(authUser.id, createAlbumDto);
|
||||
return mapAlbum(albumEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shared album, including owned and shared one.
|
||||
* @param authUser AuthUserDto
|
||||
* @returns All Shared Album And Its Members
|
||||
*/
|
||||
async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
|
||||
return albums.map((album) => mapAlbum(album));
|
||||
}
|
||||
|
||||
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
return mapAlbum(album);
|
||||
}
|
||||
|
||||
async addUsersToAlbum(authUser: AuthUserDto, addUsersDto: AddUsersDto, albumId: string): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
const updatedAlbum = await this._albumRepository.addSharedUsers(album, addUsersDto);
|
||||
return mapAlbum(updatedAlbum);
|
||||
}
|
||||
|
||||
async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise<void> {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
await this._albumRepository.delete(album);
|
||||
}
|
||||
|
||||
async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
|
||||
const sharedUserId = userId == 'me' ? authUser.id : userId;
|
||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
if (album.ownerId != authUser.id && authUser.id != sharedUserId) {
|
||||
throw new ForbiddenException('Cannot remove a user from a album that is not owned');
|
||||
}
|
||||
if (album.ownerId == sharedUserId) {
|
||||
throw new BadRequestException('The owner of the album cannot be removed');
|
||||
}
|
||||
await this._albumRepository.removeUser(album, sharedUserId);
|
||||
}
|
||||
|
||||
// async removeUsersFromAlbum() {}
|
||||
|
||||
async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto, albumId: string): Promise<void> {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
await this._albumRepository.removeAssets(album, removeAssetsDto);
|
||||
}
|
||||
|
||||
async addAssetsToAlbum(
|
||||
authUser: AuthUserDto,
|
||||
addAssetsDto: AddAssetsDto,
|
||||
albumId: string,
|
||||
): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
const updatedAlbum = await this._albumRepository.addAssets(album, addAssetsDto);
|
||||
return mapAlbum(updatedAlbum);
|
||||
}
|
||||
|
||||
async updateAlbumTitle(
|
||||
authUser: AuthUserDto,
|
||||
updateAlbumDto: UpdateAlbumDto,
|
||||
albumId: string,
|
||||
): Promise<AlbumResponseDto> {
|
||||
// TODO: this should not come from request DTO. To be removed from here and DTO
|
||||
// if (authUser.id != updateAlbumDto.ownerId) {
|
||||
// throw new BadRequestException('Unauthorized to change album info');
|
||||
// }
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
|
||||
return mapAlbum(updatedAlbum);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
|
||||
export class AddAssetsDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
assetIds: string[];
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class AddUsersDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
sharedUserIds: string[];
|
||||
}
|
||||
12
server/apps/immich/src/api-v1/album/dto/create-album.dto.ts
Normal file
12
server/apps/immich/src/api-v1/album/dto/create-album.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumName: string;
|
||||
|
||||
@IsOptional()
|
||||
sharedWithUserIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
assetIds?: string[];
|
||||
}
|
||||
21
server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts
Normal file
21
server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsBoolean } from 'class-validator';
|
||||
|
||||
export class GetAlbumsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => {
|
||||
if (value == 'true') {
|
||||
return true;
|
||||
} else if (value == 'false') {
|
||||
return false;
|
||||
}
|
||||
return value;
|
||||
})
|
||||
/**
|
||||
* true: only shared albums
|
||||
* false: only non-shared own albums
|
||||
* undefined: shared and owned albums
|
||||
*/
|
||||
shared?: boolean;
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class RemoveAssetsDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
assetIds: string[];
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class UpdateShareAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumName: string;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AlbumEntity } from '../../../../../../libs/database/src/entities/album.entity';
|
||||
import { User, mapUser } from '../../user/response-dto/user';
|
||||
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
|
||||
|
||||
export interface AlbumResponseDto {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
albumName: string;
|
||||
createdAt: string;
|
||||
albumThumbnailAssetId: string | null;
|
||||
shared: boolean;
|
||||
sharedUsers: User[];
|
||||
assets: AssetResponseDto[];
|
||||
}
|
||||
|
||||
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
|
||||
const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
|
||||
return {
|
||||
albumName: entity.albumName,
|
||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||
createdAt: entity.createdAt,
|
||||
id: entity.id,
|
||||
ownerId: entity.ownerId,
|
||||
sharedUsers,
|
||||
shared: sharedUsers.length > 0,
|
||||
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Delete,
|
||||
Logger,
|
||||
Patch,
|
||||
HttpCode,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AssetService } from './asset.service';
|
||||
@@ -76,6 +77,10 @@ export class AssetController {
|
||||
{ asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true },
|
||||
{ jobId: savedAsset.id },
|
||||
);
|
||||
|
||||
this.wsCommunicateionGateway.server
|
||||
.to(savedAsset.userId)
|
||||
.emit('on_upload_success', JSON.stringify(assetWithThumbnail));
|
||||
} else {
|
||||
await this.assetUploadedQueue.add(
|
||||
'asset-uploaded',
|
||||
@@ -83,8 +88,6 @@ export class AssetController {
|
||||
{ jobId: savedAsset.id },
|
||||
);
|
||||
}
|
||||
|
||||
this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
|
||||
} catch (e) {
|
||||
Logger.error(`Error receiving upload file ${e}`);
|
||||
}
|
||||
@@ -171,4 +174,20 @@ export class AssetController {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check duplicated asset before uploading - for Web upload used
|
||||
*/
|
||||
@Post('/check')
|
||||
@HttpCode(200)
|
||||
async checkDuplicateAsset(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string },
|
||||
) {
|
||||
const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId);
|
||||
|
||||
return {
|
||||
isExist: res,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,6 @@ import { CommunicationModule } from '../communication/communication.module';
|
||||
],
|
||||
controllers: [AssetController],
|
||||
providers: [AssetService, BackgroundTaskService],
|
||||
exports: [],
|
||||
exports: [AssetService],
|
||||
})
|
||||
export class AssetModule {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
@@ -72,6 +72,7 @@ export class AssetService {
|
||||
return await this.assetRepository.find({
|
||||
where: {
|
||||
userId: authUser.id,
|
||||
resizePath: Not(IsNull()),
|
||||
},
|
||||
relations: ['exifInfo'],
|
||||
order: {
|
||||
@@ -243,7 +244,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
/** Sending Partial Content With HTTP Code 206 */
|
||||
|
||||
console.log('Send Range', range);
|
||||
res.status(206).set({
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
@@ -381,4 +382,15 @@ export class AssetService {
|
||||
[authUser.id],
|
||||
);
|
||||
}
|
||||
|
||||
async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) {
|
||||
const res = await this.assetRepository.findOne({
|
||||
where: {
|
||||
deviceAssetId,
|
||||
userId: authUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
return res ? true : false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
|
||||
|
||||
export interface AssetResponseDto {
|
||||
id: string;
|
||||
deviceAssetId: string;
|
||||
ownerId: string;
|
||||
deviceId: string;
|
||||
type: AssetType;
|
||||
originalPath: string;
|
||||
resizePath: string | null;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
isFavorite: boolean;
|
||||
mimeType: string | null;
|
||||
duration: string | null;
|
||||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
}
|
||||
|
||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.userId,
|
||||
deviceId: entity.deviceId,
|
||||
type: entity.type,
|
||||
originalPath: entity.originalPath,
|
||||
resizePath: entity.resizePath,
|
||||
createdAt: entity.createdAt,
|
||||
modifiedAt: entity.modifiedAt,
|
||||
isFavorite: entity.isFavorite,
|
||||
mimeType: entity.mimeType,
|
||||
duration: entity.duration,
|
||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||
|
||||
export interface ExifResponseDto {
|
||||
id: string;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
imageName: string | null;
|
||||
exifImageWidth: number | null;
|
||||
exifImageHeight: number | null;
|
||||
fileSizeInByte: number | null;
|
||||
orientation: string | null;
|
||||
dateTimeOriginal: Date | null;
|
||||
modifyDate: Date | null;
|
||||
lensModel: string | null;
|
||||
fNumber: number | null;
|
||||
focalLength: number | null;
|
||||
iso: number | null;
|
||||
exposureTime: number | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
export function mapExif(entity: ExifEntity): ExifResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
make: entity.make,
|
||||
model: entity.model,
|
||||
imageName: entity.imageName,
|
||||
exifImageWidth: entity.exifImageWidth,
|
||||
exifImageHeight: entity.exifImageHeight,
|
||||
fileSizeInByte: entity.fileSizeInByte,
|
||||
orientation: entity.orientation,
|
||||
dateTimeOriginal: entity.dateTimeOriginal,
|
||||
modifyDate: entity.modifyDate,
|
||||
lensModel: entity.lensModel,
|
||||
fNumber: entity.fNumber,
|
||||
focalLength: entity.focalLength,
|
||||
iso: entity.iso,
|
||||
exposureTime: entity.exposureTime,
|
||||
latitude: entity.latitude,
|
||||
longitude: entity.longitude,
|
||||
city: entity.city,
|
||||
state: entity.state,
|
||||
country: entity.country,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||
|
||||
export interface SmartInfoResponseDto {
|
||||
id: string;
|
||||
tags: string[] | null;
|
||||
objects: string[] | null;
|
||||
}
|
||||
|
||||
export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
tags: entity.tags,
|
||||
objects: entity.objects,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export class AuthService {
|
||||
private immichJwtService: ImmichJwtService,
|
||||
) {}
|
||||
|
||||
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> {
|
||||
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity | null> {
|
||||
const user = await this.userRepository.findOne(
|
||||
{ email: loginCredential.email },
|
||||
{
|
||||
@@ -35,9 +35,13 @@ export class AuthService {
|
||||
},
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt);
|
||||
|
||||
if (user && isAuthenticated) {
|
||||
if (isAuthenticated) {
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user