Compare commits

...

29 Commits

Author SHA1 Message Date
Alex
568436f188 Up minor version for release 2022-06-23 22:38:26 -05:00
Alex
04b59318f9 Up patch version 2022-06-23 22:21:02 -05:00
Zack Pollard
1a3d05ffc3 chore: improve default setup (#234)
* chore: remove UPLOAD_LOCATION as it isn't used in the server

* docker: remove network in docker compose as docker creates one by default

* nginx: update reverse proxy to put web at root and api at /api

* docker: remove unneeded exposed ports and docker network

Align dev setup with prod, but with ports exposed for direct connection
Most communication between services happens on the internal network, so we don't need to expose all these services.
With the nginx changes, the api and web panel are both server through the reverse proxy on / for web and /api for the API.
The only service that should expose ports is nginx as that is the entrypoint to the application.

* chore: remove CORS now we serve the api on /api in the default setup

* docs: update README.md to include /api

* Fixed docket-compose file for dev environment and websocket on web and mobile

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-06-23 22:18:50 -05:00
Alex
2f2db74d73 Remove expose PostgreSQL port on production docker-compose file 2022-06-23 13:17:31 -05:00
xpwmaosldk
ef097d15dd Clean code of shared folder (#249)
* optimize android side gradle settings

* android minsdk back to 21

* remove unused package, update linter and fix lint error

* clean code of 'shared module' with offical dart style guide

* restore uploadProfileImage method in UserService
2022-06-22 23:14:14 -05:00
xpwmaosldk
caaa474c23 Optimize android's Gradle settings and clean up mobile source code (#240)
* optimize android side gradle settings

* android minsdk back to 21

* remove unused package, update linter and fix lint error
2022-06-22 00:23:35 -05:00
Alex
63bebd92e0 Added image tagging and object detection after generaing jpeg thumbnail 2022-06-21 18:00:30 -05:00
Alex
ad36b8b10f Added f-droid metadata for build 19 2022-06-20 18:51:42 -05:00
Alex
18c22d2a6c Fix #197 app logged off when closed (#239)
* Fixed issue with app logging off after closing

* Change version to reflect minor change
2022-06-20 18:10:23 -05:00
Alex
73024edba9 Update mobile version for CI build 2022-06-20 13:33:25 -05:00
Alex
a360c0a3d7 Update mobile version for CI build 2022-06-20 13:33:19 -05:00
Matthias Rupp
34657f820f Allow zooming in image viewer (#227)
* Allow zooming in image viewer

* Use thumbnailProvider as initial provider

* Set maximum zoom level to 100%

* Implement custom swipe listener in remote_photo_view

* Dart format

* Disable swipe gestures when zoomed in (prevents panning)
2022-06-20 13:29:42 -05:00
Alex Tran
8840911f22 Use polling as primary connection for socket io 2022-06-19 18:21:11 -05:00
Alex Tran
4aa66f4156 Fixed issue socket-io cannot be connected in production build on web 2022-06-19 09:33:10 -05:00
Alex Tran
799a1c99f2 Remove debugging console.log 2022-06-19 09:13:39 -05:00
Alex Tran
c4247bfea3 Fixed issue socket-io cannot be connected in production build on web 2022-06-19 09:12:43 -05:00
Alex
1e3464fe47 Feature - Add upload functionality on Web (#231)
* Added file selector

* Extract metadata to upload files to the web

* Added request for uploading

* Generate jpeg/Webp thumbnail for asset uploaded without thumbnail data

* Added generating thumbnail for video and WebSocket broadcast after thumbnail is generated

* Added video length extraction

* Added Uploading Panel

* Added upload progress store and styling the uploaded asset

* Added condition to only show upload panel when there is upload in progress

* Remove asset from the upload list after successfully uploading

* Added WebSocket to listen to upload event on the web

* Added mechanism to check for existing assets before uploading on the web

* Added test workflow

* Update readme
2022-06-19 08:16:35 -05:00
Alex
b7603fd150 Update mobile changelog 2022-06-18 11:00:35 -05:00
Jaime Baez
517a3363d6 Refactor API for albums feature (#155)
* Rename "shared" to "album"

Prepare moving "SharedAlbums" to "Albums"

* Update server album API endpoints

* Update mobile app album endpoints

Also add `putRequest` to mobile network.service

* Add GET album collection filter

- allow to filter by owner = 'mine' | 'their'
- make sharedWithUserIds no longer required when creating an album

* Rename remaining variables to "album"

* Add ParseMeUUIDPipe to validate uuid or `me`

* Add album params validation

* Update todo in mobile album service.

* Setup e2e testing

* Add user e2e tests

* Rename database host env variable to DB_HOST

* Add some `Album` e2e tests

Also fix issues found with the tests

* Force push (try to recover DB_HOST env)

* Rename db host env variable to `DB_HOSTNAME`

* Remove unnecessary `initDb` from test-utils

The current database.config is running the migrations:
`migrationsRun: true`

* Remove `initDb` usage from album e2e test

* Update GET albums filter to `shared`

- add filter by all / shared / not shared
- add response DTOs
- add GET albums e2e tests

* Update album e2e tests for user.service changes

* Update mobile app to use album response DTOs

* Refactor album-service DB into album-registry

- DB logic refactored into album-repository making it easier to test
- add some album-service unit tests
- add `clearMocks` to jest configuration

* Finish implementing album.service unit tests

* Rename response DTO

Make them consistent with rest of the project naming

* Update debug log messages in mobile network service

* Rename table `shared_albums` to `albums`

* Rename table `asset_shared_album`

* Rename Albums `sharedAssets` to `assets`

* Update tests to match updated "delete" response

* Fixed asset cannot be compared in Set by adding Equatable package

* Remove hero effect to fixed janky animation

Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-06-18 10:56:36 -05:00
Alex
3511b69fc8 Up Minor Version 2022-06-18 09:56:56 -05:00
Zack Pollard
e6efc61b3b fix: out of memory error when uploading large assets on slow internet (#224) 2022-06-18 07:36:58 -05:00
Alex Tran
360c1d9a15 Update Readme 2022-06-14 09:41:06 -05:00
Jaime Baez
e3449f9c8f Fix 500 error on login with email not in DB (#212)
* Fix 500 error on login with email not in DB

* Fix type declarations in ServerInfoDto
2022-06-12 18:29:10 -05:00
Alex
a8e723d722 Fixed ENABLE_MAPBOX value is ignored (#223) 2022-06-12 18:19:53 -05:00
Alex Tran
d116234523 Update Readme with discord tag and new logo panel 2022-06-11 23:42:28 -05:00
Alex
dce2bc7508 Added account info panel with sign out button (#219) 2022-06-11 23:17:20 -05:00
Matthias Rupp
2bf764f560 Input validation for email and server endpoint in mobile app (#211) 2022-06-11 22:12:20 -05:00
Alex Tran
587b77e70b Fixex announcement web not close after acknowledgement 2022-06-11 19:58:16 -05:00
Alex Tran
53cd9fd8bf Fixed Github Aaction build release 2022-06-11 17:47:01 -05:00
144 changed files with 3476 additions and 1652 deletions

View File

@@ -6,7 +6,7 @@ on:
types: [published] types: [published]
jobs: jobs:
build_and_push_server_release: build_and_push_server_monorepo_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -44,43 +44,37 @@ jobs:
tags: | tags: |
altran1502/immich-server:${{ steps.previoustag.outputs.tag }} altran1502/immich-server:${{ steps.previoustag.outputs.tag }}
build_and_push_microservice_release: build_and_push_machine_learning_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
ref: "main"
fetch-depth: 0 fetch-depth: 0
- name: "Get Previous tag" - name: "Get Previous tag"
id: previoustag id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
fallback: latest fallback: latest
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0 uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v2.0.0 uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
- name: Build and push immich-microservices release
uses: docker/build-push-action@v3.0.0 uses: docker/build-push-action@v3.0.0
with: with:
context: ./microservices context: ./machine-learning
file: ./microservices/Dockerfile file: ./machine-learning/Dockerfile
platforms: linux/arm/v7,linux/amd64 platforms: linux/arm/v7,linux/amd64
push: ${{ github.event_name != 'pull_request' }} push: true
tags: | tags: |
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }} altran1502/immich-machine-learning:${{ steps.previoustag.outputs.tag }}
build_and_push_web_release: build_and_push_web_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest

17
.github/workflows/test.yml vendored Normal file
View 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

View File

@@ -5,7 +5,7 @@ dev-update:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale: 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: stage:
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans 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 docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
prod-scale: 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

View File

@@ -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"/> <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>
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main"> <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> </a>
<br/> <br/>
<br/> <br/>
<br/> <br/>
<br/> <br/>
<p align="center"> <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>
</p> </p>
# Immich # Immich
Self-hosted photo and video backup solution directly from your mobile phone. **High performance self-hosted photo and video backup solution.**
![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif) ![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
@@ -31,7 +33,7 @@ Loading ~4000 images/videos
## Screenshots ## Screenshots
### Mobile client ### Mobile
<p align="left"> <p align="left">
<img src="design/login-screen.png" width="150" title="Login With Custom URL"> <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"> <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"> <img src="design/nsc6.png" width="150" title="EXIF Info">
</p> </p>
### Web client ### Web
<p align="center"> <p align="left">
<img src="design/dashboard_photos.jpeg" width="100%" title="Home Dashboard"> <img src="design/web-home.jpeg" width="49%" title="Home Dashboard">
<img src="design/web-detail.jpeg" width="49%" title="Detail">
</p> </p>
# Note # Note
@@ -55,24 +58,20 @@ This project is under heavy development, there will be continuous functions, fea
# Features # Features
- Upload and view assets (videos/images). | | Mobile | Web |
- Auto Backup. | - | - | - |
- Download asset to local device. | Upload and view videos and photos | Yes | Yes
- Multi-user supported. | Auto backup when app is opened | Yes | N/A
- Quick navigation with drag scroll bar. | Selective album(s) for backup | Yes | N/A
- Support HEIC/HEIF Backup. | Download photos and videos to local device | Yes | Yes
- Extract and display EXIF info. | Multi-user support | Yes | Yes
- Real-time render from multi-device upload event. | Shared Albums | Yes | No
- Image Tagging/Classification based on ImageNet dataset | Quick navigation with draggable scrollbar | Yes | Yes
- Object detection based on COCO SSD. | Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
- Search assets based on tags and exif data (lens, make, model, orientation) | Metadata view (EXIF, map) | Yes | Yes
- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month) | Search by metadata, objects and image tags | Yes | No
- Show asset's location information on map (OpenStreetMap). | Administrative functions (user management) | No | Yes
- 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 # 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 3. **PostgreSQL** - Main database of the application
4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue. 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
5. **Nginx** - Load balancing and optimized file uploading. 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 ## 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 # 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. # know where can it make the request to.
# For example: If your server IP address is 10.1.11.50, the environment variable will # 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
VITE_SERVER_ENDPOINT=http://192.168.1.216:2283 VITE_SERVER_ENDPOINT=http://192.168.1.216:2283/api
``` ```
## Step 2: Start the server ## 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 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 ## 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"> <p align="left">
<img src="design/admin-registration-form.png" width="300" title="Admin Registration"> <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` `Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`
Otherwise you can: 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. - 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
design/web-admin.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
design/web-detail.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

BIN
design/web-home.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -57,7 +57,7 @@ MAPBOX_KEY=
# This is the URL of your vm/server where you host Immich, so that the web frontend # 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. # know where can it make the request to.
# For example: If your server IP address is 10.1.11.50, the environment variable will # 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 # !CAUTION! THERE IS NO FORWARD SLASH AT THE END
VITE_SERVER_ENDPOINT= VITE_SERVER_ENDPOINT=

View File

@@ -19,4 +19,4 @@ ENABLE_MAPBOX=false
# WEB # WEB
MAPBOX_KEY= MAPBOX_KEY=
VITE_SERVER_ENDPOINT=http://localhost:2283 VITE_SERVER_ENDPOINT=http://localhost:2283/api

View File

@@ -2,7 +2,7 @@ version: "3.8"
services: services:
immich-server: immich-server:
image: immich-server-dev:1.9.0 image: immich-server-dev:latest
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -20,11 +20,9 @@ services:
depends_on: depends_on:
- redis - redis
- database - database
networks:
- immich-network
immich-machine-learning: immich-machine-learning:
image: immich-machine-learning-dev:1.9.0 image: immich-machine-learning-dev:latest
build: build:
context: ../machine-learning context: ../machine-learning
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -41,11 +39,9 @@ services:
- NODE_ENV=development - NODE_ENV=development
depends_on: depends_on:
- database - database
networks:
- immich-network
immich-microservices: immich-microservices:
image: immich-microservices:1.9.0 image: immich-microservices:latest
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -60,8 +56,7 @@ services:
- NODE_ENV=development - NODE_ENV=development
depends_on: depends_on:
- database - database
networks: - immich-server
- immich-network
immich-web: immich-web:
image: immich-web-dev:1.9.0 image: immich-web-dev:1.9.0
@@ -73,20 +68,16 @@ services:
env_file: env_file:
- .env - .env
ports: ports:
- 3002:3002 - 3002:3000
- 24678:24678 - 24678:24678
volumes: volumes:
- ../web:/usr/src/app - ../web:/usr/src/app
- /usr/src/app/node_modules - /usr/src/app/node_modules
networks:
- immich-network
restart: always restart: always
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
networks:
- immich-network
database: database:
container_name: immich_postgres container_name: immich_postgres
@@ -102,8 +93,6 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports: ports:
- 5432:5432 - 5432:5432
networks:
- immich-network
nginx: nginx:
container_name: proxy_nginx container_name: proxy_nginx
@@ -115,12 +104,8 @@ services:
- 2284:443 - 2284:443
logging: logging:
driver: none driver: none
networks:
- immich-network
depends_on: depends_on:
- immich-server - immich-server
networks:
immich-network:
volumes: volumes:
pgdata: pgdata:

View File

@@ -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:

View File

@@ -4,8 +4,6 @@ services:
immich-server: immich-server:
image: altran1502/immich-server:staging image: altran1502/immich-server:staging
entrypoint: ["/bin/sh", "./start-server.sh"] entrypoint: ["/bin/sh", "./start-server.sh"]
expose:
- "3000"
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file: env_file:
@@ -15,8 +13,6 @@ services:
depends_on: depends_on:
- redis - redis
- database - database
networks:
- immich-network
restart: always restart: always
immich-microservices: immich-microservices:
@@ -31,15 +27,11 @@ services:
depends_on: depends_on:
- redis - redis
- database - database
networks:
- immich-network
restart: always restart: always
immich-machine-learning: immich-machine-learning:
image: altran1502/immich-machine-learning:staging image: altran1502/immich-machine-learning:staging
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file: env_file:
@@ -48,8 +40,6 @@ services:
- NODE_ENV=production - NODE_ENV=production
depends_on: depends_on:
- database - database
networks:
- immich-network
restart: always restart: always
immich-web: immich-web:
@@ -57,17 +47,11 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file: env_file:
- .env - .env
ports:
- 2285:3000
networks:
- immich-network
restart: always restart: always
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
networks:
- immich-network
restart: always restart: always
database: database:
@@ -82,10 +66,6 @@ services:
PG_DATA: /var/lib/postgresql/data PG_DATA: /var/lib/postgresql/data
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich-network
restart: always restart: always
nginx: nginx:
@@ -98,13 +78,9 @@ services:
- 2284:443 - 2284:443
logging: logging:
driver: none driver: none
networks:
- immich-network
depends_on: depends_on:
- immich-server - immich-server
restart: always restart: always
networks:
immich-network:
volumes: volumes:
pgdata: pgdata:

View File

@@ -2,7 +2,7 @@ version: "3.8"
services: services:
immich_server_test: immich_server_test:
image: immich-server-dev:1.9.0 image: immich-server-dev:latest
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -19,15 +19,10 @@ services:
depends_on: depends_on:
- redis - redis
- database - database
networks:
- immich_network_test
redis: redis:
container_name: immich_redis_test container_name: immich_redis_test
image: redis:6.2 image: redis:6.2
networks:
- immich_network_test
database: database:
container_name: immich_postgres_test container_name: immich_postgres_test
@@ -43,8 +38,3 @@ services:
- /var/lib/postgresql/data - /var/lib/postgresql/data
ports: ports:
- 5432:5432 - 5432:5432
networks:
- immich_network_test
networks:
immich_network_test:

View File

@@ -4,8 +4,6 @@ services:
immich-server: immich-server:
image: altran1502/immich-server:latest image: altran1502/immich-server:latest
entrypoint: ["/bin/sh", "./start-server.sh"] entrypoint: ["/bin/sh", "./start-server.sh"]
expose:
- "3000"
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file: env_file:
@@ -15,8 +13,6 @@ services:
depends_on: depends_on:
- redis - redis
- database - database
networks:
- immich-network
restart: always restart: always
immich-microservices: immich-microservices:
@@ -31,15 +27,11 @@ services:
depends_on: depends_on:
- redis - redis
- database - database
networks:
- immich-network
restart: always restart: always
immich-machine-learning: immich-machine-learning:
image: altran1502/immich-machine-learning:latest image: altran1502/immich-machine-learning:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file: env_file:
@@ -48,8 +40,6 @@ services:
- NODE_ENV=production - NODE_ENV=production
depends_on: depends_on:
- database - database
networks:
- immich-network
restart: always restart: always
immich-web: immich-web:
@@ -57,17 +47,11 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file: env_file:
- .env - .env
ports:
- 2285:3000
networks:
- immich-network
restart: always restart: always
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
networks:
- immich-network
restart: always restart: always
database: database:
@@ -82,10 +66,6 @@ services:
PG_DATA: /var/lib/postgresql/data PG_DATA: /var/lib/postgresql/data
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich-network
restart: always restart: always
nginx: nginx:
@@ -98,13 +78,9 @@ services:
- 2284:443 - 2284:443
logging: logging:
driver: none driver: none
networks:
- immich-network
depends_on: depends_on:
- immich-server - immich-server
restart: always restart: always
networks:
immich-network:
volumes: volumes:
pgdata: pgdata:

View File

@@ -19,6 +19,33 @@ server {
listen 80; listen 80;
access_log off; 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 / { location / {
# Compression # Compression
@@ -41,6 +68,6 @@ server {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_pass http://immich-server:3000; proxy_pass http://immich-web:3000;
} }
} }

View File

@@ -1,10 +1,45 @@
# This file tracks properties of this Flutter project. # This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc. # 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: version:
revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b revision: cd41fdd495f6944ecd3506c21e94c6567b073278
channel: stable channel: stable
project_type: app 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'

View File

@@ -24,6 +24,7 @@ linter:
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` 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 # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options

View File

@@ -81,5 +81,4 @@ flutter {
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:multidex:1.0.3'
} }

View File

@@ -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);
}
}

View File

@@ -6,7 +6,7 @@ buildscript {
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }

View File

@@ -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

View File

@@ -0,0 +1,2 @@
* Added zoom functionality to the image viewer
* Fixed issue with the user is logged out after turning off the app

View File

@@ -0,0 +1 @@
* Fixed WebSocket endpoint to confirm with the new settings on the server

View File

@@ -3,5 +3,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
distributionSha256Sum=0080de8491f0918e4f529a6db6820fa0b9e818ee2386117f4394f95feb1d5583 distributionSha256Sum=cd5c2958a107ee7f0722004a12d0f8559b4564c34daad7df06cffd4d12a426d0

View File

@@ -23,6 +23,8 @@ PODS:
- Flutter - Flutter
- FMDB (>= 2.7.5) - FMDB (>= 2.7.5)
- Toast (4.0.0) - Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1): - video_player_avfoundation (0.0.1):
- Flutter - Flutter
- wakelock (0.0.1): - wakelock (0.0.1):
@@ -37,6 +39,7 @@ DEPENDENCIES:
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- sqflite (from `.symlinks/plugins/sqflite/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`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`) - wakelock (from `.symlinks/plugins/wakelock/ios`)
@@ -63,6 +66,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/photo_manager/ios" :path: ".symlinks/plugins/photo_manager/ios"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/ios" :path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation: video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios" :path: ".symlinks/plugins/video_player_avfoundation/ios"
wakelock: wakelock:
@@ -80,6 +85,7 @@ SPEC CHECKSUMS:
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.11.0" version_number: "1.13.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -2,18 +2,17 @@
const String userInfoBox = "immichBoxUserInfo"; // Box const String userInfoBox = "immichBoxUserInfo"; // Box
const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1 const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2 const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
// Server endpoint const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String serverEndpointKey = 'immichBoxServerEndpoint';
// Login Info // Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
const String savedLoginInfoKey = "immichSavedLoginInfoKey"; const String savedLoginInfoKey = "immichSavedLoginInfoKey"; // Key 1
// Backup Info // Backup Info
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox"; const String hiveBackupInfoBox = "immichBackupAlbumInfoBox"; // Box
const String backupInfoKey = "immichBackupAlbumInfoKey"; const String backupInfoKey = "immichBackupAlbumInfoKey"; // Key 1
// Github Release Info // Github Release Info
const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; // Box
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1

View File

@@ -4,18 +4,19 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.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/release_info.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart'; import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'constants/hive_box.dart'; import 'constants/hive_box.dart';
void main() async { void main() async {
@@ -39,13 +40,14 @@ void main() async {
} }
class ImmichApp extends ConsumerStatefulWidget { class ImmichApp extends ConsumerStatefulWidget {
const ImmichApp({Key? key}) : super(key: key); const ImmichApp({super.key});
@override @override
_ImmichAppState createState() => _ImmichAppState(); ImmichAppState createState() => ImmichAppState();
} }
class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserver { class ImmichAppState extends ConsumerState<ImmichApp>
with WidgetsBindingObserver {
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) { switch (state) {
@@ -121,7 +123,8 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
brightness: Brightness.light, brightness: Brightness.light,
primarySwatch: Colors.indigo, primarySwatch: Colors.indigo,
fontFamily: 'WorkSans', fontFamily: 'WorkSans',
snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')), snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans')),
scaffoldBackgroundColor: immichBackgroundColor, scaffoldBackgroundColor: immichBackgroundColor,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: immichBackgroundColor, backgroundColor: immichBackgroundColor,
@@ -132,7 +135,8 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
), ),
), ),
routeInformationParser: _immichRouter.defaultRouteParser(), routeInformationParser: _immichRouter.defaultRouteParser(),
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]), routerDelegate: _immichRouter.delegate(
navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
), ),
const ImmichLoadingOverlay(), const ImmichLoadingOverlay(),
const VersionAnnouncementOverlay(), const VersionAnnouncementOverlay(),

View File

@@ -9,12 +9,14 @@ import 'package:latlong2/latlong.dart';
class ExifBottomSheet extends ConsumerWidget { class ExifBottomSheet extends ConsumerWidget {
final ImmichAssetWithExif assetDetail; final ImmichAssetWithExif assetDetail;
const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key); const ExifBottomSheet({Key? key, required this.assetDetail})
: super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
_buildMap() { _buildMap() {
return (assetDetail.exifInfo!.latitude != null && assetDetail.exifInfo!.longitude != null) return (assetDetail.exifInfo!.latitude != null &&
assetDetail.exifInfo!.longitude != null)
? Padding( ? Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0), padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container( child: Container(
@@ -25,12 +27,14 @@ class ExifBottomSheet extends ConsumerWidget {
), ),
child: FlutterMap( child: FlutterMap(
options: MapOptions( options: MapOptions(
center: LatLng(assetDetail.exifInfo!.latitude!, assetDetail.exifInfo!.longitude!), center: LatLng(assetDetail.exifInfo!.latitude!,
assetDetail.exifInfo!.longitude!),
zoom: 16.0, zoom: 16.0,
), ),
layers: [ layers: [
TileLayerOptions( 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'], subdomains: ['a', 'b', 'c'],
attributionBuilder: (_) { attributionBuilder: (_) {
return const Text( return const Text(
@@ -43,8 +47,10 @@ class ExifBottomSheet extends ConsumerWidget {
markers: [ markers: [
Marker( Marker(
anchorPos: AnchorPos.align(AnchorAlign.top), anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(assetDetail.exifInfo!.latitude!, assetDetail.exifInfo!.longitude!), point: LatLng(assetDetail.exifInfo!.latitude!,
builder: (ctx) => const Image(image: AssetImage('assets/location-pin.png')), assetDetail.exifInfo!.longitude!),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png')),
), ),
], ],
), ),
@@ -56,10 +62,14 @@ class ExifBottomSheet extends ConsumerWidget {
} }
_buildLocationText() { _buildLocationText() {
return (assetDetail.exifInfo!.city != null && assetDetail.exifInfo!.state != null) return (assetDetail.exifInfo!.city != null &&
assetDetail.exifInfo!.state != null)
? Text( ? Text(
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}", "${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(); : Container();
} }
@@ -131,7 +141,8 @@ class ExifBottomSheet extends ConsumerWidget {
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.only(bottom: 8.0),
child: Text( child: Text(
"DETAILS", "DETAILS",
style: TextStyle(fontSize: 11, color: Colors.grey[400]), style:
TextStyle(fontSize: 11, color: Colors.grey[400]),
), ),
), ),
ListTile( ListTile(
@@ -158,7 +169,8 @@ class ExifBottomSheet extends ConsumerWidget {
leading: const Icon(Icons.camera), leading: const Icon(Icons.camera),
title: Text( title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}", "${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(
fontWeight: FontWeight.bold),
), ),
subtitle: Text( subtitle: Text(
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "), "ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),

View 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();
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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 { class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
const TopControlAppBar( 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); : super(key: key);
final ImmichAsset asset; final ImmichAsset asset;
@@ -42,9 +47,11 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
onPressed: () { 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( IconButton(
iconSize: iconSize, iconSize: iconSize,

View File

@@ -1,8 +1,6 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
@@ -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/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/download_loading_indicator.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.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/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.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); ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
}, },
), ),
body: SwipeDetector( body: SafeArea(
onSwipeDown: (_) {
AutoRouter.of(context).pop();
},
onSwipeUp: (_) {
showInfo();
},
child: SafeArea(
child: Stack( child: Stack(
children: [ children: [
Center( Center(
child: Hero( child: Hero(
tag: heroTag, tag: heroTag,
child: CachedNetworkImage( child: RemotePhotoView(
fit: BoxFit.cover, thumbnailUrl: thumbnailUrl,
imageUrl: imageUrl, imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, authToken: "Bearer ${box.get(accessTokenKey)}",
fadeInDuration: const Duration(milliseconds: 250), onSwipeDown: () => AutoRouter.of(context).pop(),
errorWidget: (context, url, error) => ConstrainedBox( onSwipeUp: () => showInfo(),
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],
),
);
},
),
), ),
), ),
if (downloadAssetStatus == DownloadAssetStatus.loading) if (downloadAssetStatus == DownloadAssetStatus.loading)
@@ -130,7 +84,6 @@ class ImageViewerPage extends HookConsumerWidget {
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -1,4 +1,4 @@
import 'package:dio/dio.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
@@ -12,7 +12,7 @@ class BackUpState extends Equatable {
final BackUpProgressEnum backupProgress; final BackUpProgressEnum backupProgress;
final List<String> allAssetOnDatabase; final List<String> allAssetOnDatabase;
final double progressInPercentage; final double progressInPercentage;
final CancelToken cancelToken; final CancellationToken cancelToken;
final ServerInfo serverInfo; final ServerInfo serverInfo;
/// All available albums on the device /// All available albums on the device
@@ -43,7 +43,7 @@ class BackUpState extends Equatable {
BackUpProgressEnum? backupProgress, BackUpProgressEnum? backupProgress,
List<String>? allAssetOnDatabase, List<String>? allAssetOnDatabase,
double? progressInPercentage, double? progressInPercentage,
CancelToken? cancelToken, CancellationToken? cancelToken,
ServerInfo? serverInfo, ServerInfo? serverInfo,
List<AvailableAlbum>? availableAlbums, List<AvailableAlbum>? availableAlbums,
Set<AssetPathEntity>? selectedBackupAlbums, Set<AssetPathEntity>? selectedBackupAlbums,

View File

@@ -1,15 +1,15 @@
import 'package:dio/dio.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.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/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/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'; import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> { class BackupNotifier extends StateNotifier<BackUpState> {
@@ -19,7 +19,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
allAssetOnDatabase: const [], allAssetOnDatabase: const [],
progressInPercentage: 0, progressInPercentage: 0,
cancelToken: CancelToken(), cancelToken: CancellationToken(),
serverInfo: ServerInfo( serverInfo: ServerInfo(
diskAvailable: "0", diskAvailable: "0",
diskAvailableRaw: 0, diskAvailableRaw: 0,
@@ -54,7 +54,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
removeExcludedAlbumForBackup(album); removeExcludedAlbumForBackup(album);
} }
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album}); state = state
.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
_updateBackupAssetCount(); _updateBackupAssetCount();
} }
@@ -62,7 +63,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.selectedBackupAlbums.contains(album)) { if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album); removeAlbumForBackup(album);
} }
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album}); state = state
.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
_updateBackupAssetCount(); _updateBackupAssetCount();
} }
@@ -93,16 +95,19 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Future<void> getBackupAlbumsInfo() async { Future<void> getBackupAlbumsInfo() async {
// Get all albums on the device // Get all albums on the device
List<AvailableAlbum> availableAlbums = []; 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) { for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); 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) { if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first; 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); availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
} }
@@ -113,7 +118,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Put persistent storage info into local state of the app // Put persistent storage info into local state of the app
// Get local storage on selected backup album // 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( HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
backupInfoKey, backupInfoKey,
defaultValue: HiveBackupAlbums( defaultValue: HiveBackupAlbums(
@@ -132,7 +138,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
debugPrint("First time backup setup recent album as default"); debugPrint("First time backup setup recent album as default");
// Get album that contains all assets // 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; AssetPathEntity albumHasAllAssets = list.first;
backupAlbumInfoBox.put( backupAlbumInfoBox.put(
@@ -150,12 +157,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
try { try {
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) { for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId); 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) { for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId); var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset}); state = state.copyWith(
excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset});
} }
} catch (e) { } catch (e) {
debugPrint("[ERROR] Failed to generate album from id $e"); debugPrint("[ERROR] Failed to generate album from id $e");
@@ -172,21 +181,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<AssetEntity> assetsFromExcludedAlbums = {}; Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) { 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); assetsFromSelectedAlbums.addAll(assets);
} }
for (var album in state.excludedBackupAlbums) { 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); assetsFromExcludedAlbums.addAll(assets);
} }
Set<AssetEntity> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); Set<AssetEntity> allUniqueAssets =
List<String> allAssetOnDatabase = await _backupService.getDeviceBackupAsset(); assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
List<String> allAssetOnDatabase =
await _backupService.getDeviceBackupAsset();
// Find asset that were backup from selected albums // Find asset that were backup from selected albums
Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id)); Set<String> selectedAlbumsBackupAssets =
selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId)); Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId));
if (allUniqueAssets.isEmpty) { if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device"); debugPrint("No Asset On Device");
@@ -225,7 +240,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Hive database /// Hive database
/// ///
void _updatePersistentAlbumsSelection() { void _updatePersistentAlbumsSelection() {
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox); Box<HiveBackupAlbums> backupAlbumInfoBox =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
backupAlbumInfoBox.put( backupAlbumInfoBox.put(
backupInfoKey, backupInfoKey,
HiveBackupAlbums( HiveBackupAlbums(
@@ -266,32 +282,42 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
// Perform Backup // Perform Backup
state = state.copyWith(cancelToken: CancelToken()); state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress); _backupService.backupAsset(assetsWillBeBackup, state.cancelToken,
_onAssetUploaded, _onUploadProgress);
} else { } else {
PhotoManager.openSetting(); PhotoManager.openSetting();
} }
} }
void cancelBackup() { void cancelBackup() {
state.cancelToken.cancel('Cancel Backup'); state.cancelToken.cancel();
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0); state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
} }
void _onAssetUploaded(String deviceAssetId, String deviceId) { void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state.copyWith( state = state.copyWith(selectedAlbumsBackupAssetsIds: {
selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, deviceAssetId}, ...state.selectedAlbumsBackupAssetsIds,
allAssetOnDatabase: [...state.allAssetOnDatabase, deviceAssetId]); deviceAssetId
}, allAssetOnDatabase: [
...state.allAssetOnDatabase,
deviceAssetId
]);
if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { if (state.allUniqueAssets.length -
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0); state.selectedAlbumsBackupAssetsIds.length ==
0) {
state = state.copyWith(
backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
} }
_updateServerInfo(); _updateServerInfo();
} }
void _onUploadProgress(int sent, int total) { 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 { void _updateServerInfo() async {
@@ -325,7 +351,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
// Check if this device is enable backup by the user // 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 // check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) { if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort"); 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); return BackupNotifier(ref: ref);
}); });

View File

@@ -8,11 +8,11 @@ import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.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:immich_mobile/utils/files_helper.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http;
class BackupService { class BackupService {
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
@@ -26,17 +26,13 @@ class BackupService {
return result.cast<String>(); return result.cast<String>();
} }
backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, backupAsset(Set<AssetEntity> assetList, http.CancellationToken cancelToken,
Function(int, int) uploadProgress) async { Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgress) async {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file; File? file;
MultipartFile assetRawUploadData; http.MultipartFile? thumbnailUploadData;
MultipartFile thumbnailUploadData;
for (var entity in assetList) { for (var entity in assetList) {
try { try {
@@ -47,35 +43,27 @@ class BackupService {
} }
if (file != null) { if (file != null) {
FormData formData;
String originalFileName = await entity.titleAsync; String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0]; String fileNameWithoutPath = originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path); var fileExtension = p.extension(file.path);
var mimeType = FileHelper.getMimeType(file.path); var mimeType = FileHelper.getMimeType(file.path);
assetRawUploadData = await MultipartFile.fromFile( var fileStream = file.openRead();
file.path, var assetRawUploadData = http.MultipartFile(
"assetData",
fileStream,
file.lengthSync(),
filename: fileNameWithoutPath, filename: fileNameWithoutPath,
contentType: MediaType( contentType: MediaType(
mimeType["type"], mimeType["type"],
mimeType["subType"], 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 // Build thumbnail multipart data
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560)); var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
if (thumbnailData != null) { if (thumbnailData != null) {
thumbnailUploadData = MultipartFile.fromBytes( thumbnailUploadData = http.MultipartFile.fromBytes(
"thumbnailData",
List.from(thumbnailData), List.from(thumbnailData),
filename: fileNameWithoutPath, filename: fileNameWithoutPath,
contentType: MediaType( contentType: MediaType(
@@ -83,39 +71,37 @@ class BackupService {
"jpeg", "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( var box = Hive.box(userInfoBox);
'$savedEndpoint/asset/upload',
data: formData, var req = MultipartRequest('POST', Uri.parse('$savedEndpoint/asset/upload'),
cancelToken: cancelToken, onProgress: ((bytes, totalBytes) => uploadProgress(bytes, totalBytes)));
onSendProgress: (sent, total) => uploadProgress(sent, total), 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) { if (res.statusCode == 201) {
singleAssetDoneCb(entity.id, deviceId); singleAssetDoneCb(entity.id, deviceId);
} }
} }
} on DioError catch (e) { } on http.CancelledException {
debugPrint("DioError backupAsset: ${e.response}"); debugPrint("Backup was cancelled by the user");
if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) { return;
return;
}
continue;
} catch (e) { } catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}"); debugPrint("ERROR backupAsset: ${e.toString()}");
continue; continue;
@@ -150,3 +136,35 @@ class BackupService {
return DeviceInfoRemote.fromJson(res.toString()); 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);
}
}

View File

@@ -17,16 +17,21 @@ class BackupControllerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider); BackUpState backupState = ref.watch(backupProvider);
AuthenticationState _authenticationState = ref.watch(authenticationProvider); AuthenticationState authenticationState = ref.watch(authenticationProvider);
bool shouldBackup = bool shouldBackup = backupState.allUniqueAssets.length -
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true; backupState.selectedAlbumsBackupAssetsIds.length ==
0
? false
: true;
useEffect(() { useEffect(() {
if (backupState.backupProgress != BackUpProgressEnum.inProgress) { if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
ref.read(backupProvider.notifier).getBackupInfo(); ref.read(backupProvider.notifier).getBackupInfo();
} }
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success'); ref
.watch(websocketProvider.notifier)
.stopListenToEvent('on_upload_success');
return null; return null;
}, []); }, []);
@@ -48,7 +53,8 @@ class BackupControllerPage extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: LinearPercentIndicator( child: LinearPercentIndicator(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2), barRadius: const Radius.circular(2),
lineHeight: 6.0, lineHeight: 6.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0, percent: backupState.serverInfo.diskUsagePercentage / 100.0,
@@ -58,7 +64,8 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 12.0), 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() { ListTile _buildBackupController() {
var backUpOption = _authenticationState.deviceInfo.isAutoBackup ? "on" : "off"; var backUpOption =
var isAutoBackup = _authenticationState.deviceInfo.isAutoBackup; authenticationState.deviceInfo.isAutoBackup ? "on" : "off";
var backupBtnText = _authenticationState.deviceInfo.isAutoBackup ? "off" : "on"; var isAutoBackup = authenticationState.deviceInfo.isAutoBackup;
var backupBtnText =
authenticationState.deviceInfo.isAutoBackup ? "off" : "on";
return ListTile( return ListTile(
isThreeLine: true, isThreeLine: true,
leading: isAutoBackup leading: isAutoBackup
@@ -104,10 +113,15 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
onPressed: () { onPressed: () {
isAutoBackup isAutoBackup
? ref.watch(authenticationProvider.notifier).setAutoBackup(false) ? ref
: ref.watch(authenticationProvider.notifier).setAutoBackup(true); .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), padding: const EdgeInsets.only(top: 8.0),
child: Text( child: Text(
text.trim().substring(0, text.length - 2), 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 { } else {
@@ -141,7 +158,10 @@ class BackupControllerPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Text( child: Text(
"None selected", "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), padding: const EdgeInsets.only(top: 8.0),
child: Text( child: Text(
text.trim().substring(0, text.length - 2), 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 { } else {
@@ -181,7 +204,8 @@ class BackupControllerPage extends HookConsumerWidget {
borderOnForeground: false, borderOnForeground: false,
child: ListTile( child: ListTile(
minVerticalPadding: 15, 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( subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Column( child: Column(
@@ -258,13 +282,16 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
BackupInfoCard( BackupInfoCard(
title: "Backup", 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}", info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
), ),
BackupInfoCard( BackupInfoCard(
title: "Remainder", title: "Remainder",
subtitle: "Photos and videos that has not been backing up from selected albums", subtitle:
info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}", "Photos and videos that has not been backing up from selected albums",
info:
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
), ),
const Divider(), const Divider(),
_buildBackupController(), _buildBackupController(),
@@ -289,29 +316,32 @@ class BackupControllerPage extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Container( child: Container(
child: backupState.backupProgress == BackUpProgressEnum.inProgress child:
? ElevatedButton( backupState.backupProgress == BackUpProgressEnum.inProgress
style: ElevatedButton.styleFrom( ? ElevatedButton(
primary: Colors.red[300], style: ElevatedButton.styleFrom(
onPrimary: Colors.grey[50], primary: Colors.red[300],
), onPrimary: Colors.grey[50],
onPressed: () { ),
ref.read(backupProvider.notifier).cancelBackup(); onPressed: () {
}, ref.read(backupProvider.notifier).cancelBackup();
child: const Text("Cancel"), },
) child: const Text("Cancel"),
: ElevatedButton( )
style: ElevatedButton.styleFrom( : ElevatedButton(
primary: Theme.of(context).primaryColor, style: ElevatedButton.styleFrom(
onPrimary: Colors.grey[50], primary: Theme.of(context).primaryColor,
), onPrimary: Colors.grey[50],
onPressed: shouldBackup ),
? () { onPressed: shouldBackup
ref.read(backupProvider.notifier).startBackupProcess(); ? () {
} ref
: null, .read(backupProvider.notifier)
child: const Text("Start Backup"), .startBackupProcess();
), }
: null,
child: const Text("Start Backup"),
),
), ),
) )
], ],

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DisableMultiSelectButton extends ConsumerWidget { class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({ const DisableMultiSelectButton({
@@ -36,7 +35,8 @@ class DisableMultiSelectButton extends ConsumerWidget {
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
label: Text( label: Text(
selectedItemCount.toString(), selectedItemCount.toString(),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18), style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 18),
)), )),
), ),
), ),

View File

@@ -81,7 +81,8 @@ class DraggableScrollbar extends StatefulWidget {
this.labelTextBuilder, this.labelTextBuilder,
this.labelConstraints, this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical), }) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbRRectBuilder(scrollThumbKey, alwaysVisibleScrollThumb), scrollThumbBuilder =
_thumbRRectBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
super(key: key); super(key: key);
DraggableScrollbar.arrows({ DraggableScrollbar.arrows({
@@ -98,7 +99,8 @@ class DraggableScrollbar extends StatefulWidget {
this.labelTextBuilder, this.labelTextBuilder,
this.labelConstraints, this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical), }) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbArrowBuilder(scrollThumbKey, alwaysVisibleScrollThumb), scrollThumbBuilder =
_thumbArrowBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
super(key: key); super(key: key);
DraggableScrollbar.semicircle({ DraggableScrollbar.semicircle({
@@ -115,11 +117,12 @@ class DraggableScrollbar extends StatefulWidget {
this.labelTextBuilder, this.labelTextBuilder,
this.labelConstraints, this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical), }) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbSemicircleBuilder(heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb), scrollThumbBuilder = _thumbSemicircleBuilder(
heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb),
super(key: key); super(key: key);
@override @override
_DraggableScrollbarState createState() => _DraggableScrollbarState(); DraggableScrollbarState createState() => DraggableScrollbarState();
static buildScrollThumbAndLabel( static buildScrollThumbAndLabel(
{required Widget scrollThumb, {required Widget scrollThumb,
@@ -137,9 +140,9 @@ class DraggableScrollbar extends StatefulWidget {
children: [ children: [
ScrollLabel( ScrollLabel(
animation: labelAnimation, animation: labelAnimation,
child: labelText,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
constraints: labelConstraints, constraints: labelConstraints,
child: labelText,
), ),
scrollThumb, 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 ( return (
Color backgroundColor, Color backgroundColor,
Animation<double> thumbAnimation, Animation<double> thumbAnimation,
@@ -168,9 +172,6 @@ class DraggableScrollbar extends StatefulWidget {
foregroundPainter: ArrowCustomPainter(Colors.white), foregroundPainter: ArrowCustomPainter(Colors.white),
child: Material( child: Material(
elevation: 4.0, elevation: 4.0,
child: Container(
constraints: BoxConstraints.tight(Size(width, height)),
),
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: Radius.circular(height), topLeft: Radius.circular(height),
@@ -178,6 +179,9 @@ class DraggableScrollbar extends StatefulWidget {
topRight: const Radius.circular(4.0), topRight: const Radius.circular(4.0),
bottomRight: 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 ( return (
Color backgroundColor, Color backgroundColor,
Animation<double> thumbAnimation, Animation<double> thumbAnimation,
@@ -203,6 +208,7 @@ class DraggableScrollbar extends StatefulWidget {
BoxConstraints? labelConstraints, BoxConstraints? labelConstraints,
}) { }) {
final scrollThumb = ClipPath( final scrollThumb = ClipPath(
clipper: ArrowClipper(),
child: Container( child: Container(
height: height, height: height,
width: 20.0, width: 20.0,
@@ -213,7 +219,6 @@ class DraggableScrollbar extends StatefulWidget {
), ),
), ),
), ),
clipper: ArrowClipper(),
); );
return buildScrollThumbAndLabel( 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 ( return (
Color backgroundColor, Color backgroundColor,
Animation<double> thumbAnimation, Animation<double> thumbAnimation,
@@ -239,13 +245,13 @@ class DraggableScrollbar extends StatefulWidget {
}) { }) {
final scrollThumb = Material( final scrollThumb = Material(
elevation: 4.0, elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(7.0)),
child: Container( child: Container(
constraints: BoxConstraints.tight( constraints: BoxConstraints.tight(
Size(16.0, height), Size(16.0, height),
), ),
), ),
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(7.0)),
); );
return buildScrollThumbAndLabel( return buildScrollThumbAndLabel(
@@ -267,7 +273,8 @@ class ScrollLabel extends StatelessWidget {
final Text child; final Text child;
final BoxConstraints? constraints; 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({ const ScrollLabel({
Key? key, 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 _barOffset;
late double _viewOffset; late double _viewOffset;
late bool _isDragInProcess; late bool _isDragInProcess;
@@ -345,7 +353,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
super.dispose(); super.dispose();
} }
double get barMaxScrollExtent => context.size!.height - widget.heightScrollThumb; double get barMaxScrollExtent =>
context.size!.height - widget.heightScrollThumb;
double get barMinScrollExtent => 0; 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"); //print("LayoutBuilder constraints=$constraints");
return NotificationListener<ScrollNotification>( 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) { if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward(); _thumbAnimationController.forward();
} }
@@ -486,7 +497,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
_barOffset = barMaxScrollExtent; _barOffset = barMaxScrollExtent;
} }
double viewDelta = getScrollViewDelta(details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent); double viewDelta = getScrollViewDelta(
details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent);
_viewOffset = widget.controller.position.pixels + viewDelta; _viewOffset = widget.controller.position.pixels + viewDelta;
if (_viewOffset < widget.controller.position.minScrollExtent) { 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 / 2, startPointY - arrowWidth / 2);
path.lineTo(startPointX + arrowWidth, startPointY); path.lineTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth, startPointY + 1.0); 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.lineTo(startPointX, startPointY + 1.0);
path.close(); path.close();
@@ -575,7 +588,8 @@ class ArrowClipper extends CustomClipper<Path> {
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2); path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
path.lineTo(startPointX, startPointY); path.lineTo(startPointX, startPointY);
path.lineTo(startPointX, startPointY - 1.0); 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.lineTo(startPointX + arrowWidth, startPointY - 1.0);
path.close(); path.close();
@@ -600,7 +614,8 @@ class SlideFadeTransition extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
animation: animation, animation: animation,
builder: (context, child) => animation.value == 0.0 ? Container() : child!, builder: (context, child) =>
animation.value == 0.0 ? Container() : child!,
child: SlideTransition( child: SlideTransition(
position: Tween( position: Tween(
begin: const Offset(0.3, 0.0), begin: const Offset(0.3, 0.0),

View File

@@ -20,16 +20,18 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState _backupState = ref.watch(backupProvider); final BackUpState backupState = ref.watch(backupProvider);
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup; bool isEnableAutoBackup =
final ServerInfoState _serverInfoState = ref.watch(serverInfoProvider); ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
return SliverAppBar( return SliverAppBar(
centerTitle: true, centerTitle: true,
floating: true, floating: true,
pinned: false, pinned: false,
snap: false, snap: false,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5))),
leading: Builder( leading: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
return Stack( return Stack(
@@ -47,7 +49,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
}, },
), ),
), ),
_serverInfoState.isVersionMismatch serverInfoState.isVersionMismatch
? Positioned( ? Positioned(
bottom: 12, bottom: 12,
right: 12, right: 12,
@@ -88,7 +90,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
Stack( Stack(
alignment: AlignmentDirectional.center, alignment: AlignmentDirectional.center,
children: [ children: [
_backupState.backupProgress == BackUpProgressEnum.inProgress backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned( ? Positioned(
top: 10, top: 10,
right: 12, right: 12,
@@ -97,7 +99,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
width: 8, width: 8,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 1, 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( IconButton(
splashRadius: 25, splashRadius: 25,
iconSize: 30, iconSize: 30,
icon: _isEnableAutoBackup icon: isEnableAutoBackup
? const Icon(Icons.backup_rounded) ? const Icon(Icons.backup_rounded)
: Badge( : Badge(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
@@ -118,20 +121,23 @@ class ImmichSliverAppBar extends ConsumerWidget {
), ),
child: const Icon(Icons.backup_rounded)), child: const Icon(Icons.backup_rounded)),
onPressed: () async { 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) { if (onPop != null && onPop == true) {
onPopBack!(); onPopBack!();
} }
}, },
), ),
_backupState.backupProgress == BackUpProgressEnum.inProgress backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned( ? Positioned(
bottom: 5, bottom: 5,
child: Text( child: Text(
(_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length) (backupState.allUniqueAssets.length -
backupState.selectedAlbumsBackupAssetsIds.length)
.toString(), .toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 9, fontWeight: FontWeight.bold),
), ),
) )
: Container() : Container()

View File

@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.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/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/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -23,9 +24,10 @@ class ProfileDrawer extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey); String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
AuthenticationState _authState = ref.watch(authenticationProvider); AuthenticationState authState = ref.watch(authenticationProvider);
ServerInfoState _serverInfoState = ref.watch(serverInfoProvider); ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status; final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status;
final appInfo = useState({}); final appInfo = useState({});
var dummmy = Random().nextInt(1024); var dummmy = Random().nextInt(1024);
@@ -39,7 +41,7 @@ class ProfileDrawer extends HookConsumerWidget {
} }
_buildUserProfileImage() { _buildUserProfileImage() {
if (_authState.profileImagePath.isEmpty) { if (authState.profileImagePath.isEmpty) {
return const CircleAvatar( return const CircleAvatar(
radius: 35, radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'), backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
@@ -48,10 +50,11 @@ class ProfileDrawer extends HookConsumerWidget {
} }
if (uploadProfileImageStatus == UploadProfileStatus.idle) { if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (_authState.profileImagePath.isNotEmpty) { if (authState.profileImagePath.isNotEmpty) {
return CircleAvatar( return CircleAvatar(
radius: 35, 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, backgroundColor: Colors.transparent,
); );
} else { } else {
@@ -66,7 +69,8 @@ class ProfileDrawer extends HookConsumerWidget {
if (uploadProfileImageStatus == UploadProfileStatus.success) { if (uploadProfileImageStatus == UploadProfileStatus.success) {
return CircleAvatar( return CircleAvatar(
radius: 35, 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, backgroundColor: Colors.transparent,
); );
} }
@@ -87,15 +91,16 @@ class ProfileDrawer extends HookConsumerWidget {
} }
_pickUserProfileImage() async { _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) { if (image != null) {
var success = await ref.watch(uploadProfileImageProvider.notifier).upload(image); var success =
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
if (success) { if (success) {
ref ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
.watch(authenticationProvider.notifier) ref.read(uploadProfileImageProvider).profileImagePath);
.updateUserProfileImagePath(ref.read(uploadProfileImageProvider).profileImagePath);
} }
} }
} }
@@ -116,7 +121,10 @@ class ProfileDrawer extends HookConsumerWidget {
DrawerHeader( DrawerHeader(
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( 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, begin: Alignment.centerRight,
end: Alignment.centerLeft, end: Alignment.centerLeft,
), ),
@@ -154,7 +162,7 @@ class ProfileDrawer extends HookConsumerWidget {
], ],
), ),
Text( Text(
"${_authState.firstName} ${_authState.lastName}", "${authState.firstName} ${authState.lastName}",
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -162,7 +170,7 @@ class ProfileDrawer extends HookConsumerWidget {
), ),
), ),
Text( Text(
_authState.userEmail, authState.userEmail,
style: TextStyle(color: Colors.grey[800], fontSize: 12), style: TextStyle(color: Colors.grey[800], fontSize: 12),
) )
], ],
@@ -176,16 +184,21 @@ class ProfileDrawer extends HookConsumerWidget {
), ),
title: const Text( title: const Text(
"Sign Out", "Sign Out",
style: TextStyle(color: Colors.black54, fontSize: 14, fontWeight: FontWeight.bold), style: TextStyle(
color: Colors.black54,
fontSize: 14,
fontWeight: FontWeight.bold),
), ),
onTap: () async { onTap: () async {
bool res = await ref.read(authenticationProvider.notifier).logout(); bool res =
await ref.read(authenticationProvider.notifier).logout();
if (res) { if (res) {
ref.watch(backupProvider.notifier).cancelBackup(); ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset(); ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect(); 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( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: Text(
_serverInfoState.isVersionMismatch serverInfoState.isVersionMismatch
? _serverInfoState.versionMismatchErrorMessage ? serverInfoState.versionMismatchErrorMessage
: "Client and Server are up-to-date", : "Client and Server are up-to-date",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: style: TextStyle(
TextStyle(fontSize: 11, color: Theme.of(context).primaryColor, fontWeight: FontWeight.w600), fontSize: 11,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w600),
), ),
), ),
const Divider(), const Divider(),
@@ -254,7 +270,7 @@ class ProfileDrawer extends HookConsumerWidget {
), ),
), ),
Text( Text(
"${_serverInfoState.serverVersion.major}.${_serverInfoState.serverVersion.minor}.${_serverInfoState.serverVersion.patch}", "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}",
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.grey[500], color: Colors.grey[500],

View File

@@ -19,10 +19,11 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
ScrollController _scrollController = useScrollController(); ScrollController scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider); var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
List<Widget> _imageGridGroup = []; List<Widget> imageGridGroup = [];
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable; var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var homePageState = ref.watch(homePageStateProvider); var homePageState = ref.watch(homePageStateProvider);
useEffect(() { useEffect(() {
@@ -39,7 +40,8 @@ class HomePage extends HookConsumerWidget {
_buildSelectedItemCountIndicator() { _buildSelectedItemCountIndicator() {
return isMultiSelectEnable return isMultiSelectEnable
? DisableMultiSelectButton( ? DisableMultiSelectButton(
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect, onPressed:
ref.watch(homePageStateProvider.notifier).disableMultiSelect,
selectedItemCount: homePageState.selectedItems.length, selectedItemCount: homePageState.selectedItems.length,
) )
: Container(); : Container();
@@ -59,7 +61,7 @@ class HomePage extends HookConsumerWidget {
if (lastMonth != null) { if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) { if (currentMonth - lastMonth! != 0) {
_imageGridGroup.add( imageGridGroup.add(
MonthlyTitleText( MonthlyTitleText(
isoDate: dateGroup, isoDate: dateGroup,
), ),
@@ -67,14 +69,14 @@ class HomePage extends HookConsumerWidget {
} }
} }
_imageGridGroup.add( imageGridGroup.add(
DailyTitleText( DailyTitleText(
isoDate: dateGroup, isoDate: dateGroup,
assetGroup: immichAssetList, assetGroup: immichAssetList,
), ),
); );
_imageGridGroup.add( imageGridGroup.add(
ImageGrid(assetGroup: immichAssetList), ImageGrid(assetGroup: immichAssetList),
); );
@@ -109,12 +111,12 @@ class HomePage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 50.0), padding: const EdgeInsets.only(top: 50.0),
child: DraggableScrollbar.semicircle( child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController, controller: scrollController,
heightScrollThumb: 48.0, heightScrollThumb: 48.0,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: scrollController,
slivers: [ slivers: [
..._imageGridGroup, ...imageGridGroup,
], ],
), ),
), ),

View File

@@ -67,7 +67,7 @@ class LoginForm extends HookConsumerWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
enableFeedback: true, enableFeedback: true,
title: const Text( title: const Text(
"Save login", "Stay logged in",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey), style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey),
), ),
value: isSaveLoginInfo.value, value: isSaveLoginInfo.value,
@@ -96,12 +96,20 @@ class ServerEndpointInput extends StatelessWidget {
const ServerEndpointInput({Key? key, required this.controller}) : super(key: key); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Server Endpoint URL', border: OutlineInputBorder(), hintText: 'http://your-server-ip:port'), 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); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
decoration: decoration:
const InputDecoration(labelText: 'Email', border: OutlineInputBorder(), hintText: 'youremail@email.com'), const InputDecoration(labelText: 'Email', border: OutlineInputBorder(), hintText: 'youremail@email.com'),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
); );
} }
} }

View File

@@ -45,20 +45,23 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
} }
void getSuggestedSearchTerms() async { void getSuggestedSearchTerms() async {
var userSuggestedSearchTerms = await _searchService.getUserSuggestedSearchTerms(); var userSuggestedSearchTerms =
await _searchService.getUserSuggestedSearchTerms();
state = state.copyWith(userSuggestedSearchTerms: userSuggestedSearchTerms); state = state.copyWith(userSuggestedSearchTerms: userSuggestedSearchTerms);
} }
} }
final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) { final searchPageStateProvider =
StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
return SearchPageStateNotifier(); return SearchPageStateNotifier();
}); });
final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocation>>((ref) async { final getCuratedLocationProvider =
final SearchService _searchService = SearchService(); FutureProvider.autoDispose<List<CuratedLocation>>((ref) async {
final SearchService searchService = SearchService();
var curatedLocation = await _searchService.getCuratedLocation(); var curatedLocation = await searchService.getCuratedLocation();
if (curatedLocation != null) { if (curatedLocation != null) {
return curatedLocation; return curatedLocation;
} else { } else {
@@ -66,10 +69,11 @@ final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocati
} }
}); });
final getCuratedObjectProvider = FutureProvider.autoDispose<List<CuratedObject>>((ref) async { final getCuratedObjectProvider =
final SearchService _searchService = SearchService(); FutureProvider.autoDispose<List<CuratedObject>>((ref) async {
final SearchService searchService = SearchService();
var curatedObject = await _searchService.getCuratedObjects(); var curatedObject = await searchService.getCuratedObjects();
if (curatedObject != null) { if (curatedObject != null) {
return curatedObject; return curatedObject;
} else { } else {

View File

@@ -1,7 +1,6 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';

View File

@@ -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'; import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
class SearchResultPage extends HookConsumerWidget { 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; final String searchTerm;
late FocusNode searchFocusNode;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
ScrollController _scrollController = useScrollController(); ScrollController scrollController = useScrollController();
final searchTermController = useTextEditingController(text: ""); final searchTermController = useTextEditingController(text: "");
final isNewSearch = useState(false); final isNewSearch = useState(false);
final currentSearchTerm = useState(searchTerm); final currentSearchTerm = useState(searchTerm);
List<Widget> _imageGridGroup = []; final List<Widget> imageGridGroup = [];
late FocusNode searchFocusNode;
useEffect(() { useEffect(() {
searchFocusNode = FocusNode(); 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(); return () => searchFocusNode.dispose();
}, []); }, []);
@@ -85,7 +88,10 @@ class SearchResultPage extends HookConsumerWidget {
children: [ children: [
Text( Text(
currentSearchTerm.value, 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, maxLines: 1,
), ),
Icon( Icon(
@@ -124,7 +130,7 @@ class SearchResultPage extends HookConsumerWidget {
if (lastMonth != null) { if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) { if (currentMonth - lastMonth! != 0) {
_imageGridGroup.add( imageGridGroup.add(
MonthlyTitleText( MonthlyTitleText(
isoDate: dateGroup, isoDate: dateGroup,
), ),
@@ -132,14 +138,14 @@ class SearchResultPage extends HookConsumerWidget {
} }
} }
_imageGridGroup.add( imageGridGroup.add(
DailyTitleText( DailyTitleText(
isoDate: dateGroup, isoDate: dateGroup,
assetGroup: immichAssetList, assetGroup: immichAssetList,
), ),
); );
_imageGridGroup.add( imageGridGroup.add(
ImageGrid(assetGroup: immichAssetList), ImageGrid(assetGroup: immichAssetList),
); );
@@ -148,11 +154,11 @@ class SearchResultPage extends HookConsumerWidget {
return DraggableScrollbar.semicircle( return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController, controller: scrollController,
heightScrollThumb: 48.0, heightScrollThumb: 48.0,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: scrollController,
slivers: [..._imageGridGroup], slivers: [...imageGridGroup],
), ),
); );
} else { } else {
@@ -192,7 +198,9 @@ class SearchResultPage extends HookConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
_buildSearchResult(), _buildSearchResult(),
isNewSearch.value ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(), isNewSearch.value
? SearchSuggestionList(onSubmitted: _onSearchSubmitted)
: Container(),
], ],
), ),
), ),

View File

@@ -2,8 +2,8 @@ import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/modules/sharing/models/shared_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/modules/sharing/models/shared_user.model.dart'; import 'package:immich_mobile/shared/models/user.model.dart';
class SharedAlbum { class SharedAlbum {
final String id; final String id;
@@ -11,8 +11,8 @@ class SharedAlbum {
final String albumName; final String albumName;
final String createdAt; final String createdAt;
final String? albumThumbnailAssetId; final String? albumThumbnailAssetId;
final List<SharedUsers> sharedUsers; final List<User> sharedUsers;
final List<SharedAssets>? sharedAssets; final List<ImmichAsset>? assets;
SharedAlbum({ SharedAlbum({
required this.id, required this.id,
@@ -21,7 +21,7 @@ class SharedAlbum {
required this.createdAt, required this.createdAt,
required this.albumThumbnailAssetId, required this.albumThumbnailAssetId,
required this.sharedUsers, required this.sharedUsers,
this.sharedAssets, this.assets,
}); });
SharedAlbum copyWith({ SharedAlbum copyWith({
@@ -30,8 +30,8 @@ class SharedAlbum {
String? albumName, String? albumName,
String? createdAt, String? createdAt,
String? albumThumbnailAssetId, String? albumThumbnailAssetId,
List<SharedUsers>? sharedUsers, List<User>? sharedUsers,
List<SharedAssets>? sharedAssets, List<ImmichAsset>? assets,
}) { }) {
return SharedAlbum( return SharedAlbum(
id: id ?? this.id, id: id ?? this.id,
@@ -40,7 +40,7 @@ class SharedAlbum {
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId, albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId,
sharedUsers: sharedUsers ?? this.sharedUsers, sharedUsers: sharedUsers ?? this.sharedUsers,
sharedAssets: sharedAssets ?? this.sharedAssets, assets: assets ?? this.assets,
); );
} }
@@ -55,8 +55,8 @@ class SharedAlbum {
result.addAll({'albumThumbnailAssetId': albumThumbnailAssetId}); result.addAll({'albumThumbnailAssetId': albumThumbnailAssetId});
} }
result.addAll({'sharedUsers': sharedUsers.map((x) => x.toMap()).toList()}); result.addAll({'sharedUsers': sharedUsers.map((x) => x.toMap()).toList()});
if (sharedAssets != null) { if (assets != null) {
result.addAll({'sharedAssets': sharedAssets!.map((x) => x.toMap()).toList()}); result.addAll({'assets': assets!.map((x) => x.toMap()).toList()});
} }
return result; return result;
@@ -69,9 +69,9 @@ class SharedAlbum {
albumName: map['albumName'] ?? '', albumName: map['albumName'] ?? '',
createdAt: map['createdAt'] ?? '', createdAt: map['createdAt'] ?? '',
albumThumbnailAssetId: map['albumThumbnailAssetId'], albumThumbnailAssetId: map['albumThumbnailAssetId'],
sharedUsers: List<SharedUsers>.from(map['sharedUsers']?.map((x) => SharedUsers.fromMap(x))), sharedUsers: List<User>.from(map['sharedUsers']?.map((x) => User.fromMap(x))),
sharedAssets: map['sharedAssets'] != null assets: map['assets'] != null
? List<SharedAssets>.from(map['sharedAssets']?.map((x) => SharedAssets.fromMap(x))) ? List<ImmichAsset>.from(map['assets']?.map((x) => ImmichAsset.fromMap(x)))
: null, : null,
); );
} }
@@ -82,7 +82,7 @@ class SharedAlbum {
@override @override
String toString() { 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 @override
@@ -97,7 +97,7 @@ class SharedAlbum {
other.createdAt == createdAt && other.createdAt == createdAt &&
other.albumThumbnailAssetId == albumThumbnailAssetId && other.albumThumbnailAssetId == albumThumbnailAssetId &&
listEquals(other.sharedUsers, sharedUsers) && listEquals(other.sharedUsers, sharedUsers) &&
listEquals(other.sharedAssets, sharedAssets); listEquals(other.assets, assets);
} }
@override @override
@@ -108,6 +108,6 @@ class SharedAlbum {
createdAt.hashCode ^ createdAt.hashCode ^
albumThumbnailAssetId.hashCode ^ albumThumbnailAssetId.hashCode ^
sharedUsers.hashCode ^ sharedUsers.hashCode ^
sharedAssets.hashCode; assets.hashCode;
} }
} }

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -8,7 +8,8 @@ class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
final SharedAlbumService _sharedAlbumService = SharedAlbumService(); final SharedAlbumService _sharedAlbumService = SharedAlbumService();
getAllSharedAlbums() async { getAllSharedAlbums() async {
List<SharedAlbum> sharedAlbums = await _sharedAlbumService.getAllSharedAlbum(); List<SharedAlbum> sharedAlbums =
await _sharedAlbumService.getAllSharedAlbum();
state = sharedAlbums; 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); var res = await _sharedAlbumService.removeAssetFromAlbum(albumId, assetIds);
if (res) { 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(); return SharedAlbumNotifier();
}); });
final sharedAlbumDetailProvider = FutureProvider.autoDispose.family<SharedAlbum, String>((ref, albumId) async { final sharedAlbumDetailProvider = FutureProvider.autoDispose
final SharedAlbumService _sharedAlbumService = SharedAlbumService(); .family<SharedAlbum, String>((ref, albumId) async {
final SharedAlbumService sharedAlbumService = SharedAlbumService();
return await _sharedAlbumService.getAlbumDetail(albumId); return await sharedAlbumService.getAlbumDetail(albumId);
}); });

View File

@@ -1,8 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; 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'; 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(); UserService userService = UserService();
return await userService.getAllUsersInfo(); return await userService.getAllUsersInfo();

View File

@@ -12,9 +12,10 @@ class SharedAlbumService {
Future<List<SharedAlbum>> getAllSharedAlbum() async { Future<List<SharedAlbum>> getAllSharedAlbum() async {
try { 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<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; return result;
} catch (e) { } catch (e) {
@@ -24,9 +25,10 @@ class SharedAlbumService {
return []; 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 { try {
var res = await _networkService.postRequest(url: 'shared/createAlbum', data: { var res = await _networkService.postRequest(url: 'album', data: {
"albumName": albumName, "albumName": albumName,
"sharedWithUserIds": sharedUserIds, "sharedWithUserIds": sharedUserIds,
"assetIds": assets.map((asset) => asset.id).toList(), "assetIds": assets.map((asset) => asset.id).toList(),
@@ -45,7 +47,7 @@ class SharedAlbumService {
Future<SharedAlbum> getAlbumDetail(String albumId) async { Future<SharedAlbum> getAlbumDetail(String albumId) async {
try { try {
var res = await _networkService.getRequest(url: 'shared/$albumId'); var res = await _networkService.getRequest(url: 'album/$albumId');
dynamic decodedData = jsonDecode(res.toString()); dynamic decodedData = jsonDecode(res.toString());
SharedAlbum result = SharedAlbum.fromMap(decodedData); 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 { try {
var res = await _networkService.postRequest(url: 'shared/addAssets', data: { var res =
await _networkService.putRequest(url: 'album/$albumId/assets', data: {
"albumId": albumId, "albumId": albumId,
"assetIds": assets.map((asset) => asset.id).toList(), "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 { try {
var res = await _networkService.postRequest(url: 'shared/addUsers', data: { var res =
"albumId": albumId, await _networkService.putRequest(url: 'album/$albumId/users', data: {
"sharedUserIds": sharedUserIds, "sharedUserIds": sharedUserIds,
}); });
@@ -93,7 +98,7 @@ class SharedAlbumService {
Future<bool> deleteAlbum(String albumId) async { Future<bool> deleteAlbum(String albumId) async {
try { try {
Response res = await _networkService.deleteRequest(url: 'shared/$albumId'); Response res = await _networkService.deleteRequest(url: 'album/$albumId');
if (res.statusCode != 200) { if (res.statusCode != 200) {
return false; return false;
@@ -108,7 +113,8 @@ class SharedAlbumService {
Future<bool> leaveAlbum(String albumId) async { Future<bool> leaveAlbum(String albumId) async {
try { try {
Response res = await _networkService.deleteRequest(url: 'shared/leaveAlbum/$albumId'); Response res =
await _networkService.deleteRequest(url: 'album/$albumId/user/me');
if (res.statusCode != 200) { if (res.statusCode != 200) {
return false; 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 { try {
Response res = await _networkService.deleteRequest(url: 'shared/removeAssets/', data: { Response res = await _networkService
"albumId": albumId, .deleteRequest(url: 'album/$albumId/assets', data: {
"assetIds": assetIds, "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 { try {
Response res = await _networkService.patchRequest(url: 'shared/updateInfo', data: { Response res =
"albumId": albumId, await _networkService.patchRequest(url: 'album/$albumId/', data: {
"ownerId": ownerId, "ownerId": ownerId,
"albumName": newAlbumTitle, "albumName": newAlbumTitle,
}); });

View File

@@ -26,18 +26,22 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable; final isMultiSelectionEnable =
final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; ref.watch(assetSelectionProvider).isMultiselectEnable;
final selectedAssetsInAlbum =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
void _onDeleteAlbumPressed(String albumId) async { void _onDeleteAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(albumId); bool isSuccess =
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(albumId);
if (isSuccess) { if (isSuccess) {
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()])); AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else { } else {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
@@ -53,10 +57,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
void _onLeaveAlbumPressed(String albumId) async { void _onLeaveAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(albumId); bool isSuccess =
await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(albumId);
if (isSuccess) { if (isSuccess) {
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()])); AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else { } else {
Navigator.pop(context); Navigator.pop(context);
ImmichToast.show( ImmichToast.show(
@@ -73,10 +79,11 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
void _onRemoveFromAlbumPressed(String albumId) async { void _onRemoveFromAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum( bool isSuccess =
albumId, await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
selectedAssetsInAlbum.map((a) => a.id).toList(), albumId,
); selectedAssetsInAlbum.map((a) => a.id).toList(),
);
if (isSuccess) { if (isSuccess) {
Navigator.pop(context); Navigator.pop(context);
@@ -153,15 +160,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
_buildLeadingButton() { _buildLeadingButton() {
if (isMultiSelectionEnable) { if (isMultiSelectionEnable) {
return IconButton( return IconButton(
onPressed: () => ref.watch(assetSelectionProvider.notifier).disableMultiselection(), onPressed: () => ref
.watch(assetSelectionProvider.notifier)
.disableMultiselection(),
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
splashRadius: 25, splashRadius: 25,
); );
} else if (isEditAlbum) { } else if (isEditAlbum) {
return IconButton( return IconButton(
onPressed: () async { onPressed: () async {
bool isSuccess = bool isSuccess = await ref
await ref.watch(albumViewerProvider.notifier).changeAlbumTitle(albumId, userId, newAlbumTitle); .watch(albumViewerProvider.notifier)
.changeAlbumTitle(albumId, userId, newAlbumTitle);
if (!isSuccess) { if (!isSuccess) {
ImmichToast.show( ImmichToast.show(
@@ -187,7 +197,9 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
return AppBar( return AppBar(
elevation: 0, elevation: 0,
leading: _buildLeadingButton(), leading: _buildLeadingButton(),
title: isMultiSelectionEnable ? Text(selectedAssetsInAlbum.length.toString()) : Container(), title: isMultiSelectionEnable
? Text(selectedAssetsInAlbum.length.toString())
: Container(),
centerTitle: false, centerTitle: false,
actions: [ actions: [
IconButton( IconButton(

View File

@@ -86,63 +86,60 @@ class SelectionThumbnailImage extends HookConsumerWidget {
} }
} }
}, },
child: Hero( child: Stack(
tag: asset.id, children: [
child: Stack( Container(
children: [ decoration: BoxDecoration(border: drawBorderColor()),
Container( child: CachedNetworkImage(
decoration: BoxDecoration(border: drawBorderColor()), cacheKey: "${asset.id}-${cacheKey.value}",
child: CachedNetworkImage( width: 150,
cacheKey: "${asset.id}-${cacheKey.value}", height: 150,
width: 150, memCacheHeight: asset.type == 'IMAGE' ? 150 : 150,
height: 150, fit: BoxFit.cover,
memCacheHeight: asset.type == 'IMAGE' ? 150 : 150, imageUrl: thumbnailRequestUrl,
fit: BoxFit.cover, httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
imageUrl: thumbnailRequestUrl, fadeInDuration: const Duration(milliseconds: 250),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
fadeInDuration: const Duration(milliseconds: 250), scale: 0.2,
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( child: CircularProgressIndicator(value: downloadProgress.progress),
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
), ),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
), ),
Padding( ),
padding: const EdgeInsets.all(3.0), Padding(
child: Align( padding: const EdgeInsets.all(3.0),
alignment: Alignment.topLeft, child: Align(
child: _buildSelectionIcon(asset), alignment: Alignment.topLeft,
), child: _buildSelectionIcon(asset),
), ),
asset.type == 'IMAGE' ),
? Container() asset.type == 'IMAGE'
: Positioned( ? Container()
bottom: 5, : Positioned(
right: 5, bottom: 5,
child: Row( right: 5,
children: [ child: Row(
Text( children: [
asset.duration.toString().substring(0, 7), Text(
style: const TextStyle( asset.duration.toString().substring(0, 7),
color: Colors.white, style: const TextStyle(
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white, color: Colors.white,
fontSize: 10,
), ),
], ),
), const Icon(
) Icons.play_circle_outline_rounded,
], color: Colors.white,
), ),
],
),
)
],
), ),
); );
} }

View File

@@ -23,32 +23,29 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
onTap: () { onTap: () {
// debugPrint("View ${asset.id}"); // debugPrint("View ${asset.id}");
}, },
child: Hero( child: Stack(
tag: asset.id, children: [
child: Stack( CachedNetworkImage(
children: [ cacheKey: "${asset.id}-${cacheKey.value}",
CachedNetworkImage( width: 500,
cacheKey: "${asset.id}-${cacheKey.value}", height: 500,
width: 500, memCacheHeight: asset.type == 'IMAGE' ? 500 : 500,
height: 500, fit: BoxFit.cover,
memCacheHeight: asset.type == 'IMAGE' ? 500 : 500, imageUrl: thumbnailRequestUrl,
fit: BoxFit.cover, httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
imageUrl: thumbnailRequestUrl, fadeInDuration: const Duration(milliseconds: 250),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
fadeInDuration: const Duration(milliseconds: 250), scale: 0.2,
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( child: CircularProgressIndicator(value: downloadProgress.progress),
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
), ),
], errorWidget: (context, url, error) {
), return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
],
), ),
); );
} }

View File

@@ -28,32 +28,33 @@ class AlbumViewerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode(); FocusNode titleFocusNode = useFocusNode();
ScrollController _scrollController = useScrollController(); ScrollController scrollController = useScrollController();
AsyncValue<SharedAlbum> _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId)); AsyncValue<SharedAlbum> albumInfo =
ref.watch(sharedAlbumDetailProvider(albumId));
final userId = ref.watch(authenticationProvider).userId; final userId = ref.watch(authenticationProvider).userId;
/// Find out if the assets in album exist on the device /// 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. /// If they exist, add to selected asset state to show they are already selected.
void _onAddPhotosPressed(SharedAlbum albumInfo) async { void _onAddPhotosPressed(SharedAlbum albumInfo) async {
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
ref ref
.watch(assetSelectionProvider.notifier) .watch(assetSelectionProvider.notifier)
.addNewAssets(albumInfo.sharedAssets!.map((e) => e.assetInfo).toList()); .addNewAssets(albumInfo.assets!.toList());
} }
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true); ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
AssetSelectionPageResult? returnPayload = AssetSelectionPageResult? returnPayload = await AutoRouter.of(context)
await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute()); .push<AssetSelectionPageResult?>(const AssetSelectionRoute());
if (returnPayload != null) { if (returnPayload != null) {
// Check if there is new assets add // Check if there is new assets add
if (returnPayload.selectedAdditionalAsset.isNotEmpty) { if (returnPayload.selectedAdditionalAsset.isNotEmpty) {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
var isSuccess = var isSuccess = await SharedAlbumService().addAdditionalAssetToAlbum(
await SharedAlbumService().addAdditionalAssetToAlbum(returnPayload.selectedAdditionalAsset, albumId); returnPayload.selectedAdditionalAsset, albumId);
if (isSuccess) { if (isSuccess) {
ref.refresh(sharedAlbumDetailProvider(albumId)); ref.refresh(sharedAlbumDetailProvider(albumId));
@@ -69,13 +70,15 @@ class AlbumViewerPage extends HookConsumerWidget {
} }
void _onAddUsersPressed(SharedAlbum albumInfo) async { void _onAddUsersPressed(SharedAlbum albumInfo) async {
List<String>? sharedUserIds = List<String>? sharedUserIds = await AutoRouter.of(context)
await AutoRouter.of(context).push<List<String>?>(SelectAdditionalUserForSharingRoute(albumInfo: albumInfo)); .push<List<String>?>(
SelectAdditionalUserForSharingRoute(albumInfo: albumInfo));
if (sharedUserIds != null) { if (sharedUserIds != null) {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
var isSuccess = await SharedAlbumService().addAdditionalUserToAlbum(sharedUserIds, albumId); var isSuccess = await SharedAlbumService()
.addAdditionalUserToAlbum(sharedUserIds, albumId);
if (isSuccess) { if (isSuccess) {
ref.refresh(sharedAlbumDetailProvider(albumId)); ref.refresh(sharedAlbumDetailProvider(albumId));
@@ -95,16 +98,20 @@ class AlbumViewerPage extends HookConsumerWidget {
) )
: Padding( : Padding(
padding: const EdgeInsets.only(left: 8.0), 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) { Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
String startDate = ""; String startDate = "";
DateTime parsedStartDate = DateTime.parse(albumInfo.sharedAssets!.first.assetInfo.createdAt); DateTime parsedStartDate =
DateTime parsedEndDate = DateTime.parse(albumInfo.sharedAssets!.last.assetInfo.createdAt); DateTime.parse(albumInfo.assets!.first.createdAt);
DateTime parsedEndDate =
DateTime.parse(albumInfo.assets!.last.createdAt);
if (parsedStartDate.year == parsedEndDate.year) { if (parsedStartDate.year == parsedEndDate.year) {
startDate = DateFormat('LLL d').format(parsedStartDate); startDate = DateFormat('LLL d').format(parsedStartDate);
@@ -118,7 +125,8 @@ class AlbumViewerPage extends HookConsumerWidget {
padding: const EdgeInsets.only(left: 16.0, top: 8), padding: const EdgeInsets.only(left: 16.0, top: 8),
child: Text( child: Text(
"$startDate-$endDate", "$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 { } else {
@@ -147,8 +155,9 @@ class AlbumViewerPage extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(2.0), padding: const EdgeInsets.all(2.0),
child: ClipRRect( child: ClipRRect(
child: Image.asset('assets/immich-logo-no-outline.png'),
borderRadius: BorderRadius.circular(50.0), 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) { Widget _buildImageGrid(SharedAlbum albumInfo) {
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
return SliverPadding( return SliverPadding(
padding: const EdgeInsets.only(top: 10.0), padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid( sliver: SliverGrid(
@@ -174,9 +183,9 @@ class AlbumViewerPage extends HookConsumerWidget {
), ),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (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( child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController, controller: scrollController,
heightScrollThumb: 48.0, heightScrollThumb: 48.0,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: scrollController,
slivers: [ slivers: [
_buildHeader(albumInfo), _buildHeader(albumInfo),
SliverPersistentHeader( SliverPersistentHeader(
@@ -242,8 +251,9 @@ class AlbumViewerPage extends HookConsumerWidget {
} }
return Scaffold( return Scaffold(
appBar: AlbumViewerAppbar(albumInfo: _albumInfo, userId: userId, albumId: albumId), appBar: AlbumViewerAppbar(
body: _albumInfo.when( albumInfo: albumInfo, userId: userId, albumId: albumId),
body: albumInfo.when(
data: (albumInfo) => _buildBody(albumInfo), data: (albumInfo) => _buildBody(albumInfo),
error: (e, _) => Center(child: Text("Error loading album info $e")), error: (e, _) => Center(child: Text("Error loading album info $e")),
loading: () => const Center( loading: () => const Center(

View File

@@ -13,13 +13,15 @@ class AssetSelectionPage extends HookConsumerWidget {
const AssetSelectionPage({Key? key}) : super(key: key); const AssetSelectionPage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
ScrollController _scrollController = useScrollController(); ScrollController scrollController = useScrollController();
var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider); var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider);
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; final selectedAssets =
final newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
List<Widget> _imageGridGroup = []; List<Widget> imageGridGroup = [];
String _buildAssetCountText() { String _buildAssetCountText() {
if (isAlbumExist) { if (isAlbumExist) {
@@ -31,19 +33,20 @@ class AssetSelectionPage extends HookConsumerWidget {
Widget _buildBody() { Widget _buildBody() {
assetGroupMonthYear.forEach((monthYear, assetGroup) { assetGroupMonthYear.forEach((monthYear, assetGroup) {
_imageGridGroup.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup)); imageGridGroup
_imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup)); .add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup));
}); });
return Stack( return Stack(
children: [ children: [
DraggableScrollbar.semicircle( DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController, controller: scrollController,
heightScrollThumb: 48.0, heightScrollThumb: 48.0,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: scrollController,
slivers: [..._imageGridGroup], slivers: [...imageGridGroup],
), ),
), ),
], ],
@@ -71,7 +74,8 @@ class AssetSelectionPage extends HookConsumerWidget {
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
(!isAlbumExist && selectedAssets.isNotEmpty) || (isAlbumExist && newAssetsForAlbum.isNotEmpty) (!isAlbumExist && selectedAssets.isNotEmpty) ||
(isAlbumExist && newAssetsForAlbum.isNotEmpty)
? TextButton( ? TextButton(
onPressed: () { onPressed: () {
var payload = AssetSelectionPageResult( var payload = AssetSelectionPageResult(

View File

@@ -15,11 +15,13 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final albumTitleController = useTextEditingController.fromValue(TextEditingValue.empty); final albumTitleController =
useTextEditingController.fromValue(TextEditingValue.empty);
final albumTitleTextFieldFocusNode = useFocusNode(); final albumTitleTextFieldFocusNode = useFocusNode();
final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true); final isAlbumTitleEmpty = useState(true);
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; final selectedAssets =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
_showSelectUserPage() { _showSelectUserPage() {
AutoRouter.of(context).push(const SelectUserForSharingRoute()); AutoRouter.of(context).push(const SelectUserForSharingRoute());
@@ -38,8 +40,8 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
_onSelectPhotosButtonPressed() async { _onSelectPhotosButtonPressed() async {
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false); ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
AssetSelectionPageResult? selectedAsset = AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context)
await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute()); .push<AssetSelectionPageResult?>(const AssetSelectionRoute());
if (selectedAsset == null) { if (selectedAsset == null) {
ref.watch(assetSelectionProvider.notifier).removeAll(); ref.watch(assetSelectionProvider.notifier).removeAll();
@@ -84,16 +86,22 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
child: OutlinedButton.icon( child: OutlinedButton.icon(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 22, horizontal: 16), padding:
side: const BorderSide(color: Color.fromARGB(255, 206, 206, 206)), const EdgeInsets.symmetric(vertical: 22, horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5))), side: const BorderSide(
color: Color.fromARGB(255, 206, 206, 206)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5))),
onPressed: _onSelectPhotosButtonPressed, onPressed: _onSelectPhotosButtonPressed,
icon: const Icon(Icons.add_rounded), icon: const Icon(Icons.add_rounded),
label: Padding( label: Padding(
padding: const EdgeInsets.only(left: 8.0), padding: const EdgeInsets.only(left: 8.0),
child: Text( child: Text(
'Select Photos', '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) { (BuildContext context, int index) {
return GestureDetector( return GestureDetector(
onTap: _onBackgroundTapped, onTap: _onBackgroundTapped,
child: SharedAlbumThumbnailImage(asset: selectedAssets.toList()[index]), child: SharedAlbumThumbnailImage(
asset: selectedAssets.toList()[index]),
); );
}, },
childCount: selectedAssets.length, childCount: selectedAssets.length,
@@ -169,7 +178,9 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: albumTitleController.text.isNotEmpty ? _showSelectUserPage : null, onPressed: albumTitleController.text.isNotEmpty
? _showSelectUserPage
: null,
child: const Text( child: const Text(
'Share', 'Share',
style: TextStyle( style: TextStyle(
@@ -189,13 +200,13 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
pinned: true, pinned: true,
floating: false, floating: false,
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: const Size.fromHeight(66.0),
child: Column( child: Column(
children: [ children: [
_buildTitleInputField(), _buildTitleInputField(),
_buildControlButton(), _buildControlButton(),
], ],
), ),
preferredSize: const Size.fromHeight(66.0),
), ),
), ),
_buildTitle(), _buildTitle(),

View File

@@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/models/shared_album.model.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/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'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class SelectAdditionalUserForSharingPage extends HookConsumerWidget { class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
@@ -14,14 +14,14 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); AsyncValue<List<User>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
final sharedUsersList = useState<Set<UserInfo>>({}); final sharedUsersList = useState<Set<User>>({});
_addNewUsersHandler() { _addNewUsersHandler() {
AutoRouter.of(context).pop(sharedUsersList.value.map((e) => e.id).toList()); AutoRouter.of(context).pop(sharedUsersList.value.map((e) => e.id).toList());
} }
_buildTileIcon(UserInfo user) { _buildTileIcon(User user) {
if (sharedUsersList.value.contains(user)) { if (sharedUsersList.value.contains(user)) {
return CircleAvatar( return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
@@ -38,7 +38,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
} }
} }
_buildUserList(List<UserInfo> users) { _buildUserList(List<User> users) {
List<Widget> usersChip = []; List<Widget> usersChip = [];
for (var user in sharedUsersList.value) { for (var user in sharedUsersList.value) {
@@ -120,7 +120,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
body: suggestedShareUsers.when( body: suggestedShareUsers.when(
data: (users) { data: (users) {
for (var sharedUsers in albumInfo.sharedUsers) { 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); return _buildUserList(users);

View File

@@ -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/providers/suggested_shared_users.provider.dart';
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart'; import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
import 'package:immich_mobile/routing/router.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'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class SelectUserForSharingPage extends HookConsumerWidget { class SelectUserForSharingPage extends HookConsumerWidget {
const SelectUserForSharingPage({Key? key}) : super(key: key); const SelectUserForSharingPage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final sharedUsersList = useState<Set<UserInfo>>({}); final sharedUsersList = useState<Set<User>>({});
AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); AsyncValue<List<User>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
_createSharedAlbum() async { _createSharedAlbum() async {
var isSuccess = await SharedAlbumService().createSharedAlbum( var isSuccess = await SharedAlbumService().createSharedAlbum(
@@ -36,7 +36,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
const ScaffoldMessenger(child: SnackBar(content: Text('Failed to create album'))); const ScaffoldMessenger(child: SnackBar(content: Text('Failed to create album')));
} }
_buildTileIcon(UserInfo user) { _buildTileIcon(User user) {
if (sharedUsersList.value.contains(user)) { if (sharedUsersList.value.contains(user)) {
return CircleAvatar( return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
@@ -53,7 +53,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
} }
} }
_buildUserList(List<UserInfo> users) { _buildUserList(List<User> users) {
List<Widget> usersChip = []; List<Widget> usersChip = [];
for (var user in sharedUsersList.value) { for (var user in sharedUsersList.value) {

View File

@@ -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/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.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/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/shared/views/tab_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
@@ -27,8 +28,9 @@ part 'router.gr.dart';
@MaterialAutoRouter( @MaterialAutoRouter(
replaceInRouteName: 'Page,Route', replaceInRouteName: 'Page,Route',
routes: <AutoRoute>[ routes: <AutoRoute>[
AutoRoute(page: LoginPage, initial: true), AutoRoute(page: SplashScreenPage, initial: true),
AutoRoute( AutoRoute(page: LoginPage),
CustomRoute(
page: TabControllerPage, page: TabControllerPage,
guards: [AuthGuard], guards: [AuthGuard],
children: [ children: [
@@ -36,6 +38,7 @@ part 'router.gr.dart';
AutoRoute(page: SearchPage, guards: [AuthGuard]), AutoRoute(page: SearchPage, guards: [AuthGuard]),
AutoRoute(page: SharingPage, guards: [AuthGuard]) AutoRoute(page: SharingPage, guards: [AuthGuard])
], ],
transitionsBuilder: TransitionsBuilders.fadeIn,
), ),
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]), AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]), AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),

View File

@@ -21,13 +21,21 @@ class _$AppRouter extends RootStackRouter {
@override @override
final Map<String, PageFactory> pagesMap = { final Map<String, PageFactory> pagesMap = {
SplashScreenRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const SplashScreenPage());
},
LoginRoute.name: (routeData) { LoginRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, child: const LoginPage()); routeData: routeData, child: const LoginPage());
}, },
TabControllerRoute.name: (routeData) { TabControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>( return CustomPage<dynamic>(
routeData: routeData, child: const TabControllerPage()); routeData: routeData,
child: const TabControllerPage(),
transitionsBuilder: TransitionsBuilders.fadeIn,
opaque: true,
barrierDismissible: false);
}, },
ImageViewerRoute.name: (routeData) { ImageViewerRoute.name: (routeData) {
final args = routeData.argsAs<ImageViewerRouteArgs>(); final args = routeData.argsAs<ImageViewerRouteArgs>();
@@ -121,7 +129,8 @@ class _$AppRouter extends RootStackRouter {
@override @override
List<RouteConfig> get routes => [ List<RouteConfig> get routes => [
RouteConfig(LoginRoute.name, path: '/'), RouteConfig(SplashScreenRoute.name, path: '/'),
RouteConfig(LoginRoute.name, path: '/login-page'),
RouteConfig(TabControllerRoute.name, RouteConfig(TabControllerRoute.name,
path: '/tab-controller-page', path: '/tab-controller-page',
guards: [ 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 /// generated route for
/// [LoginPage] /// [LoginPage]
class LoginRoute extends PageRouteInfo<void> { class LoginRoute extends PageRouteInfo<void> {
const LoginRoute() : super(LoginRoute.name, path: '/'); const LoginRoute() : super(LoginRoute.name, path: '/login-page');
static const String name = 'LoginRoute'; static const String name = 'LoginRoute';
} }

View File

@@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:ffi';
class DeviceInfoRemote { class DeviceInfoRemote {
final int id; final int id;
@@ -66,7 +65,8 @@ class DeviceInfoRemote {
String toJson() => json.encode(toMap()); 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 @override
String toString() { String toString() {

View File

@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
class ImmichAsset { import 'package:equatable/equatable.dart';
class ImmichAsset extends Equatable {
final String id; final String id;
final String deviceAssetId; final String deviceAssetId;
final String userId; final String userId;
@@ -13,7 +15,7 @@ class ImmichAsset {
final String originalPath; final String originalPath;
final String resizePath; final String resizePath;
ImmichAsset({ const ImmichAsset({
required this.id, required this.id,
required this.deviceAssetId, required this.deviceAssetId,
required this.userId, required this.userId,
@@ -56,19 +58,23 @@ class ImmichAsset {
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { final result = <String, dynamic>{};
'id': id,
'deviceAssetId': deviceAssetId, result.addAll({'id': id});
'userId': userId, result.addAll({'deviceAssetId': deviceAssetId});
'deviceId': deviceId, result.addAll({'userId': userId});
'type': type, result.addAll({'deviceId': deviceId});
'createdAt': createdAt, result.addAll({'type': type});
'modifiedAt': modifiedAt, result.addAll({'createdAt': createdAt});
'isFavorite': isFavorite, result.addAll({'modifiedAt': modifiedAt});
'duration': duration, result.addAll({'isFavorite': isFavorite});
'originalPath': originalPath, if (duration != null) {
'resizePath': resizePath, result.addAll({'duration': duration});
}; }
result.addAll({'originalPath': originalPath});
result.addAll({'resizePath': resizePath});
return result;
} }
factory ImmichAsset.fromMap(Map<String, dynamic> map) { factory ImmichAsset.fromMap(Map<String, dynamic> map) {
@@ -97,35 +103,7 @@ class ImmichAsset {
} }
@override @override
bool operator ==(Object other) { List<Object> get props {
if (identical(this, other)) return true; return [id];
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;
} }
} }

View File

@@ -1,25 +1,33 @@
import 'dart:convert'; import 'dart:convert';
class UserInfo { class User {
final String id; final String id;
final String email; final String email;
final String createdAt; final String createdAt;
final String firstName;
final String lastName;
UserInfo({ User({
required this.id, required this.id,
required this.email, required this.email,
required this.createdAt, required this.createdAt,
required this.firstName,
required this.lastName,
}); });
UserInfo copyWith({ User copyWith({
String? id, String? id,
String? email, String? email,
String? createdAt, String? createdAt,
String? firstName,
String? lastName,
}) { }) {
return UserInfo( return User(
id: id ?? this.id, id: id ?? this.id,
email: email ?? this.email, email: email ?? this.email,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
); );
} }
@@ -33,17 +41,19 @@ class UserInfo {
return result; return result;
} }
factory UserInfo.fromMap(Map<String, dynamic> map) { factory User.fromMap(Map<String, dynamic> map) {
return UserInfo( return User(
id: map['id'] ?? '', id: map['id'] ?? '',
email: map['email'] ?? '', email: map['email'] ?? '',
createdAt: map['createdAt'] ?? '', createdAt: map['createdAt'] ?? '',
firstName: map['firstName'] ?? '',
lastName: map['lastName'] ?? '',
); );
} }
String toJson() => json.encode(toMap()); 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 @override
String toString() => 'UserInfo(id: $id, email: $email, createdAt: $createdAt)'; String toString() => 'UserInfo(id: $id, email: $email, createdAt: $createdAt)';
@@ -52,7 +62,12 @@ class UserInfo {
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; 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 @override

View File

@@ -33,12 +33,12 @@ class AssetNotifier extends StateNotifier<List<ImmichAsset>> {
deleteAssets(Set<ImmichAsset> deleteAssets) async { deleteAssets(Set<ImmichAsset> deleteAssets) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"]; var deviceId = deviceInfo["deviceId"];
List<String> deleteIdList = []; var deleteIdList = <String>[];
// Delete asset from device // Delete asset from device
for (var asset in deleteAssets) { for (var asset in deleteAssets) {
// Delete asset on device if present // Delete asset on device if present
if (asset.deviceId == deviceId) { if (asset.deviceId == deviceId) {
AssetEntity? localAsset = await AssetEntity.fromId(asset.deviceAssetId); var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
if (localAsset != null) { if (localAsset != null) {
deleteIdList.add(localAsset.id); 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 // Delete asset on server
List<DeleteAssetResponse>? deleteAssetResult = await _assetService.deleteAssets(deleteAssets); List<DeleteAssetResponse>? deleteAssetResult =
await _assetService.deleteAssets(deleteAssets);
if (deleteAssetResult == null) { if (deleteAssetResult == null) {
return; return;
} }
for (var asset in deleteAssetResult) { for (var asset in deleteAssetResult) {
if (asset.status == 'success') { 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); return AssetNotifier(ref);
}); });
final assetGroupByDateTimeProvider = StateProvider((ref) { final assetGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider); var assets = ref.watch(assetProvider);
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a)); assets.sortByCompare<DateTime>(
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt))); (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) { final assetGroupByMonthYearProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider); 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)));
}); });

View File

@@ -15,7 +15,7 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
try { try {
String? localReleaseVersion = box.get(githubReleaseInfoKey); String? localReleaseVersion = box.get(githubReleaseInfoKey);
Response res = await dio.get( var res = await dio.get(
"https://api.github.com/repos/alextran1502/immich/releases/latest", "https://api.github.com/repos/alextran1502/immich/releases/latest",
options: Options( options: Options(
headers: {"Accept": "application/vnd.github.v3+json"}, headers: {"Accept": "application/vnd.github.v3+json"},
@@ -34,7 +34,8 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
return; return;
} }
if (latestTagVersion.isNotEmpty && localReleaseVersion != latestTagVersion) { if (latestTagVersion.isNotEmpty &&
localReleaseVersion != latestTagVersion) {
VersionAnnouncementOverlayController.appLoader.show(); VersionAnnouncementOverlayController.appLoader.show();
return; return;
} }
@@ -54,4 +55,5 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
} }
} }
final releaseInfoProvider = StateNotifierProvider<ReleaseInfoNotifier, String>((ref) => ReleaseInfoNotifier()); final releaseInfoProvider = StateNotifierProvider<ReleaseInfoNotifier, String>(
(ref) => ReleaseInfoNotifier());

View File

@@ -11,7 +11,8 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
: super( : super(
ServerInfoState( ServerInfoState(
mapboxInfo: MapboxInfo(isEnable: false, mapboxSecret: ""), 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, isVersionMismatch: false,
versionMismatchErrorMessage: "", versionMismatchErrorMessage: "",
), ),
@@ -33,7 +34,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
state = state.copyWith(serverVersion: serverVersion); state = state.copyWith(serverVersion: serverVersion);
PackageInfo packageInfo = await PackageInfo.fromPlatform(); var packageInfo = await PackageInfo.fromPlatform();
Map<String, int> appVersion = _getDetailVersion(packageInfo.version); Map<String, int> appVersion = _getDetailVersion(packageInfo.version);
@@ -57,7 +58,8 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
return; return;
} }
state = state.copyWith(isVersionMismatch: false, versionMismatchErrorMessage: ""); state = state.copyWith(
isVersionMismatch: false, versionMismatchErrorMessage: "");
} }
Map<String, int> _getDetailVersion(String version) { 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(); return ServerInfoNotifier();
}); });

View File

@@ -3,12 +3,11 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/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/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/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 { class WebscoketState {
final Socket? socket; final Socket? socket;
@@ -60,8 +59,9 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
debugPrint("[WEBSOCKET] Attempting to connect to ws"); debugPrint("[WEBSOCKET] Attempting to connect to ws");
// Configure socket transports must be sepecified // Configure socket transports must be sepecified
Socket socket = io( Socket socket = io(
endpoint, endpoint.toString().replaceAll('/api', ''),
OptionBuilder() OptionBuilder()
.setPath('/api/socket.io')
.setTransports(['websocket']) .setTransports(['websocket'])
.enableReconnection() .enableReconnection()
.enableForceNew() .enableForceNew()

View File

@@ -4,8 +4,8 @@ import 'dart:io' show Platform;
class DeviceInfoService { class DeviceInfoService {
Future<Map<String, dynamic>> getDeviceInfo() async { Future<Map<String, dynamic>> getDeviceInfo() async {
// Get device info // Get device info
String deviceId = await FlutterUdid.consistentUdid; var deviceId = await FlutterUdid.consistentUdid;
String deviceType = ""; var deviceType = "";
if (Platform.isAndroid) { if (Platform.isAndroid) {
deviceType = "ANDROID"; deviceType = "ANDROID";

View File

@@ -4,15 +4,22 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:hive/hive.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/constants/hive_box.dart';
import 'package:immich_mobile/utils/dio_http_interceptor.dart'; import 'package:immich_mobile/utils/dio_http_interceptor.dart';
import 'package:immich_mobile/utils/files_helper.dart';
class NetworkService { class NetworkService {
late final Dio dio;
NetworkService() {
dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
}
Future<dynamic> deleteRequest({required String url, dynamic data}) async { Future<dynamic> deleteRequest({required String url, dynamic data}) async {
try { try {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
Response res = await dio.delete('$savedEndpoint/$url', data: data); Response res = await dio.delete('$savedEndpoint/$url', data: data);
@@ -22,15 +29,15 @@ class NetworkService {
} on DioError catch (e) { } on DioError catch (e) {
debugPrint("DioError: ${e.response}"); debugPrint("DioError: ${e.response}");
} catch (e) { } 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 { try {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
if (isByteResponse) { if (isByteResponse) {
@@ -66,63 +73,66 @@ class NetworkService {
Future<dynamic> postRequest({required String url, dynamic data}) async { Future<dynamic> postRequest({required String url, dynamic data}) async {
try { try {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
String validUrl = Uri.parse('$savedEndpoint/$url').toString(); var validUrl = Uri.parse('$savedEndpoint/$url').toString();
Response res = await dio.post(validUrl, data: data); var res = await dio.post(validUrl, data: data);
return res; return res;
} on DioError catch (e) { } on DioError catch (e) {
debugPrint("DioError: ${e.response}"); debugPrint("DioError: ${e.response}");
return null; return null;
} catch (e) { } 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; return null;
} }
} }
Future<dynamic> patchRequest({required String url, dynamic data}) async { Future<dynamic> patchRequest({required String url, dynamic data}) async {
try { try {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var validUrl = Uri.parse('$savedEndpoint/$url').toString();
String validUrl = Uri.parse('$savedEndpoint/$url').toString(); var res = await dio.patch(validUrl, data: data);
Response res = await dio.patch(validUrl, data: data);
return res; return res;
} on DioError catch (e) { } on DioError catch (e) {
debugPrint("DioError: ${e.response}"); debugPrint("DioError: ${e.response}");
} catch (e) { } catch (e) {
debugPrint("ERROR BackupService: $e"); debugPrint("ERROR PatchRequest: $e");
} }
} }
Future<bool> pingServer() async { Future<bool> pingServer() async {
try { try {
var dio = Dio();
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); 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"); var res = await dio.get(validUrl);
Response res = await dio.get(validUrl);
var jsonRespsonse = jsonDecode(res.toString()); var jsonRespsonse = jsonDecode(res.toString());
if (jsonRespsonse["res"] == "pong") { return jsonRespsonse["res"] == "pong";
return true;
} else {
return false;
}
} on DioError catch (e) { } on DioError catch (e) {
debugPrint("[PING SERVER] DioError: ${e.response} - $e"); debugPrint("[PING SERVER] DioError: ${e.response} - $e");
return false; return false;
} catch (e) { } catch (e) {
debugPrint("ERROR BackupService: $e"); debugPrint("ERROR PingServer: $e");
return false; return false;
} }
} }

View File

@@ -1,9 +1,8 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.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/models/server_version.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart';
class ServerInfoService { class ServerInfoService {
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();

View File

@@ -3,23 +3,23 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:http_parser/http_parser.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.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/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/shared/services/network.service.dart';
import 'package:immich_mobile/utils/dio_http_interceptor.dart'; import 'package:immich_mobile/utils/dio_http_interceptor.dart';
import 'package:immich_mobile/utils/files_helper.dart'; import 'package:immich_mobile/utils/files_helper.dart';
import 'package:http_parser/http_parser.dart';
class UserService { class UserService {
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
Future<List<UserInfo>> getAllUsersInfo() async { Future<List<User>> getAllUsersInfo() async {
try { try {
Response res = await _networkService.getRequest(url: 'user'); var res = await _networkService.getRequest(url: 'user');
List<dynamic> decodedData = jsonDecode(res.toString()); 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; return result;
} catch (e) { } catch (e) {

View File

@@ -10,12 +10,10 @@ class ImmichToast {
ToastType toastType = ToastType.info, ToastType toastType = ToastType.info,
ToastGravity gravity = ToastGravity.TOP, ToastGravity gravity = ToastGravity.TOP,
}) { }) {
FToast fToast; final fToast = FToast();
fToast = FToast();
fToast.init(context); fToast.init(context);
_getColor(ToastType type, BuildContext context) { Color _getColor(ToastType type, BuildContext context) {
switch (type) { switch (type) {
case ToastType.info: case ToastType.info:
return Theme.of(context).primaryColor; 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( fToast.showToast(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
@@ -40,24 +58,7 @@ class ImmichToast {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
(toastType == ToastType.info) _getIcon(toastType),
? 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(),
const SizedBox( const SizedBox(
width: 12.0, width: 12.0,
), ),

View File

@@ -9,25 +9,25 @@ class ImmichLoadingOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: ImmichLoadingOverlayController.appLoader.loaderShowingNotifier, valueListenable:
ImmichLoadingOverlayController.appLoader.loaderShowingNotifier,
builder: (context, shouldShow, child) { builder: (context, shouldShow, child) {
if (shouldShow) { return shouldShow
return const Scaffold( ? const Scaffold(
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
body: Center( body: Center(
child: ImmichLoadingIndicator(), child: ImmichLoadingIndicator(),
), ),
); )
} else { : const SizedBox();
return Container();
}
}, },
); );
} }
} }
class ImmichLoadingOverlayController { class ImmichLoadingOverlayController {
static final ImmichLoadingOverlayController appLoader = ImmichLoadingOverlayController(); static final ImmichLoadingOverlayController appLoader =
ImmichLoadingOverlayController();
ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false); ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false);
ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message'); ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message');

View 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,
),
),
),
],
),
),
);
}
}

View File

@@ -12,8 +12,9 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
void goToReleaseNote() async { void goToReleaseNote() async {
final Uri _url = Uri.parse('https://github.com/alextran1502/immich/releases/latest'); final Uri url =
await launchUrl(_url); Uri.parse('https://github.com/alextran1502/immich/releases/latest');
await launchUrl(url);
} }
void onAcknowledgeTapped() { void onAcknowledgeTapped() {
@@ -21,7 +22,8 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
} }
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: VersionAnnouncementOverlayController.appLoader.loaderShowingNotifier, valueListenable:
VersionAnnouncementOverlayController.appLoader.loaderShowingNotifier,
builder: (context, shouldShow, child) { builder: (context, shouldShow, child) {
if (shouldShow) { if (shouldShow) {
return Scaffold( return Scaffold(
@@ -51,10 +53,14 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
child: RichText( child: RichText(
text: TextSpan( text: TextSpan(
style: const TextStyle( 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>[ children: <TextSpan>[
const TextSpan( const TextSpan(
text: 'Hi friend, there is a new release of', text:
'Hi friend, there is a new release of',
), ),
const TextSpan( const TextSpan(
text: ' Immich ', text: ' Immich ',
@@ -65,19 +71,21 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
), ),
), ),
const TextSpan( const TextSpan(
text: "please take your time to visit the ", text:
"please take your time to visit the ",
), ),
TextSpan( TextSpan(
text: "release note", text: "release note",
style: const TextStyle( style: const TextStyle(
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
), ),
recognizer: TapGestureRecognizer()..onTap = goToReleaseNote, recognizer: TapGestureRecognizer()
..onTap = goToReleaseNote,
), ),
const TextSpan( const TextSpan(
text: 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.", " 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(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
shape: const StadiumBorder(), shape: const StadiumBorder(),
visualDensity: VisualDensity.standard, visualDensity: VisualDensity.standard,
primary: Colors.indigo, primary: Colors.indigo,
onPrimary: Colors.grey[50], onPrimary: Colors.grey[50],
elevation: 2, elevation: 2,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25), 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 { class VersionAnnouncementOverlayController {
static final VersionAnnouncementOverlayController appLoader = VersionAnnouncementOverlayController(); static final VersionAnnouncementOverlayController appLoader =
VersionAnnouncementOverlayController();
ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false); ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false);
ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message'); ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message');

View File

@@ -1,5 +1,4 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';

View File

@@ -141,6 +141,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" 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: characters:
dependency: transitive dependency: transitive
description: description:
@@ -184,7 +198,7 @@ packages:
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@@ -219,7 +233,7 @@ packages:
source: hosted source: hosted
version: "0.17.1" version: "0.17.1"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: transitive
description: description:
name: cupertino_icons name: cupertino_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@@ -320,7 +334,7 @@ packages:
name: flutter_lints name: flutter_lints
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" version: "2.0.1"
flutter_map: flutter_map:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -437,7 +451,7 @@ packages:
source: hosted source: hosted
version: "0.15.0" version: "0.15.0"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@@ -451,12 +465,12 @@ packages:
source: hosted source: hosted
version: "3.2.0" version: "3.2.0"
http_parser: http_parser:
dependency: transitive dependency: "direct main"
description: description:
name: http_parser name: http_parser
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.0" version: "4.0.1"
image: image:
dependency: transitive dependency: transitive
description: description:
@@ -528,7 +542,7 @@ packages:
source: hosted source: hosted
version: "4.5.0" version: "4.5.0"
latlong2: latlong2:
dependency: transitive dependency: "direct main"
description: description:
name: latlong2 name: latlong2
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@@ -540,7 +554,7 @@ packages:
name: lints name: lints
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "2.0.0"
lists: lists:
dependency: transitive dependency: transitive
description: description:
@@ -654,19 +668,19 @@ packages:
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.1" version: "1.8.1"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.10" version: "2.0.11"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
@@ -743,7 +757,7 @@ packages:
name: photo_view name: photo_view
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.13.0" version: "0.14.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -847,13 +861,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" 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: socket_io_client:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1127,13 +1134,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.10" 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: wakelock:
dependency: transitive dependency: transitive
description: description:

View File

@@ -2,15 +2,15 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.11.0+17 version: 1.13.0+20
environment: environment:
sdk: ">=2.15.1 <3.0.0" sdk: ">=2.17.0 <3.0.0"
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
cupertino_icons: ^1.0.2
photo_manager: ^2.0.6 photo_manager: ^2.0.6
flutter_hooks: ^0.18.0 flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0 hooks_riverpod: ^2.0.0-dev.0
@@ -23,14 +23,12 @@ dependencies:
auto_route: ^4.0.1 auto_route: ^4.0.1
exif: ^3.1.1 exif: ^3.1.1
transparent_image: ^2.0.0 transparent_image: ^2.0.0
visibility_detector: ^0.2.2
flutter_launcher_icons: "^0.9.2" flutter_launcher_icons: "^0.9.2"
fluttertoast: ^8.0.8 fluttertoast: ^8.0.8
video_player: ^2.2.18 video_player: ^2.2.18
chewie: ^1.2.2 chewie: ^1.2.2
sliver_tools: ^0.2.5
badges: ^2.0.2 badges: ^2.0.2
photo_view: ^0.13.0 photo_view: ^0.14.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0 socket_io_client: ^2.0.0-beta.4-nullsafety.0
flutter_map: ^0.14.0 flutter_map: ^0.14.0
flutter_udid: ^2.0.0 flutter_udid: ^2.0.0
@@ -40,11 +38,19 @@ dependencies:
equatable: ^2.0.3 equatable: ^2.0.3
image_picker: ^0.8.5+3 image_picker: ^0.8.5+3
url_launcher: ^6.1.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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^1.0.0 flutter_lints: ^2.0.1
hive_generator: ^1.1.2 hive_generator: ^1.1.2
build_runner: ^2.1.7 build_runner: ^2.1.7
auto_route_generator: ^4.0.0 auto_route_generator: ^4.0.0

View 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);
}
}

View 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);
}
}

View 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 {}

View 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);
});
});

View 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);
}
}

View File

@@ -1,10 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { IsNotEmpty } from 'class-validator';
import { AssetEntity } from '@app/database/entities/asset.entity';
export class AddAssetsDto { export class AddAssetsDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty() @IsNotEmpty()
assetIds: string[]; assetIds: string[];
} }

View File

@@ -1,9 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { IsNotEmpty } from 'class-validator';
export class AddUsersDto { export class AddUsersDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty() @IsNotEmpty()
sharedUserIds: string[]; sharedUserIds: string[];
} }

View File

@@ -0,0 +1,12 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateAlbumDto {
@IsNotEmpty()
albumName: string;
@IsOptional()
sharedWithUserIds?: string[];
@IsOptional()
assetIds?: string[];
}

View 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;
}

View File

@@ -1,9 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { IsNotEmpty } from 'class-validator';
export class RemoveAssetsDto { export class RemoveAssetsDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty() @IsNotEmpty()
assetIds: string[]; assetIds: string[];
} }

View File

@@ -1,9 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { IsNotEmpty } from 'class-validator';
export class UpdateShareAlbumDto { export class UpdateAlbumDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty() @IsNotEmpty()
albumName: string; albumName: string;

View File

@@ -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)) || [],
};
}

View File

@@ -15,6 +15,7 @@ import {
Delete, Delete,
Logger, Logger,
Patch, Patch,
HttpCode,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
@@ -76,6 +77,10 @@ export class AssetController {
{ asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true }, { asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true },
{ jobId: savedAsset.id }, { jobId: savedAsset.id },
); );
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(assetWithThumbnail));
} else { } else {
await this.assetUploadedQueue.add( await this.assetUploadedQueue.add(
'asset-uploaded', 'asset-uploaded',
@@ -83,8 +88,6 @@ export class AssetController {
{ jobId: savedAsset.id }, { jobId: savedAsset.id },
); );
} }
this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
} catch (e) { } catch (e) {
Logger.error(`Error receiving upload file ${e}`); Logger.error(`Error receiving upload file ${e}`);
} }
@@ -171,4 +174,20 @@ export class AssetController {
return result; 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,
};
}
} }

View File

@@ -24,6 +24,6 @@ import { CommunicationModule } from '../communication/communication.module';
], ],
controllers: [AssetController], controllers: [AssetController],
providers: [AssetService, BackgroundTaskService], providers: [AssetService, BackgroundTaskService],
exports: [], exports: [AssetService],
}) })
export class AssetModule {} export class AssetModule {}

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common'; import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { IsNull, Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
@@ -72,6 +72,7 @@ export class AssetService {
return await this.assetRepository.find({ return await this.assetRepository.find({
where: { where: {
userId: authUser.id, userId: authUser.id,
resizePath: Not(IsNull()),
}, },
relations: ['exifInfo'], relations: ['exifInfo'],
order: { order: {
@@ -243,7 +244,7 @@ export class AssetService {
} }
/** Sending Partial Content With HTTP Code 206 */ /** Sending Partial Content With HTTP Code 206 */
console.log('Send Range', range);
res.status(206).set({ res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
@@ -381,4 +382,15 @@ export class AssetService {
[authUser.id], [authUser.id],
); );
} }
async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) {
const res = await this.assetRepository.findOne({
where: {
deviceAssetId,
userId: authUser.id,
},
});
return res ? true : false;
}
} }

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -17,7 +17,7 @@ export class AuthService {
private immichJwtService: ImmichJwtService, private immichJwtService: ImmichJwtService,
) {} ) {}
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> { private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity | null> {
const user = await this.userRepository.findOne( const user = await this.userRepository.findOne(
{ email: loginCredential.email }, { 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); const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt);
if (user && isAuthenticated) { if (isAuthenticated) {
return user; return user;
} }

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