Compare commits

...

33 Commits

Author SHA1 Message Date
Alex Tran
32240777c3 fixed release build directory for Github action 2022-06-30 00:10:01 -05:00
Alex Tran
6065ff8caa Update readme with new discord invitation link 2022-06-29 23:50:24 -05:00
Alex Tran
8db073941d Up server version for release 2022-06-29 21:54:57 -05:00
Alex
5e281b44e9 Add Podman Support (#278) 2022-06-29 21:49:35 -05:00
Zack Pollard
142ede350e feat: create immich-nginx container to remove default nginx config setup (#280)
* feat: create immich-proxy container to remove default nginx config setup

* infra: make production docker-compose point at release builds for stability

* Fixed nginx config file was overriden by default.conf in nginx container; Fixed docker-compose.dev; Added additional tag 'release' for tagging after release build in Github Action

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-06-29 21:24:55 -05:00
Alex
a2e1d4caa2 Update server dependencies and fixed Typeorm API changes in new version (#276)
* Fixed dependencies

* Adapt typeorm API to be compatible with new version

* Fixed typeorm API in tests

* Remove console.log
2022-06-29 13:39:58 -05:00
Alex
5f00d8b9c6 Added mechanism of required password change of new user's first login (#272)
* Deprecate login scenarios that support pre-web era

* refactor and simplify setup

* Added user info to change password form

* change isFistLogin column to shouldChangePassword

* Implemented change user password

* Implement the change password page for mobile

* Change label

* Added changes log and up minor version

* Fixed typo in the release note

* Up server version
2022-06-27 15:13:07 -05:00
bo0tzz
2e85e18020 Use APP_UPLOAD_LOCATION constant for diskInfo (#271) 2022-06-27 09:14:32 -05:00
Alex
40a8115101 Fix backup not resuming after closed and reopen (#266)
* Fixed app not resuming backup after closing and reopening the app

* Fixed cosmetic effect of backup button doesn't change state right away after pressing start backup

* Fixed grammar

* Fixed deep copy problem that cause incorrect asset count when backing up

* Format code
2022-06-25 15:12:47 -05:00
xpwmaosldk
d02b97e1c1 Add service provider (#250)
* 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

* add service provider

* fix searchFocusNode init error
2022-06-25 13:46:51 -05:00
Alex Tran
485b152beb Improved info panel on web 2022-06-25 13:28:36 -05:00
Jaime Baez
c918f5b001 Set TypeScript to strict mode and fix issues related to server types (#261)
* Fix lint issues and some other TS issues

- set TypeScript in strict mode
- add npm commands to lint / check code
- fix all lint issues
- fix some TS issues
- rename User reponse DTO to make it consistent with the other ones
- override Express/User interface to use UserResponseDto interface
 This is for when the accessing the `user` from a Express Request,
 like in `asset-upload-config`

* Fix the rest of TS issues

- fix all the remaining TypeScript errors
- add missing `@types/mapbox__mapbox-sdk` package

* Move global.d.ts to server `src` folder

* Update AssetReponseDto duration type

This is now of type `string` that defaults to '0:00:00.00000' if not set
which is what the mobile app currently expects

* Set context when logging error in asset.service

Use `ServeFile` as the context for logging an error when
asset.resizePath is not set

* Fix wrong AppController merge conflict resolution

`redirectToWebpage` was removed in main as is no longer used.
2022-06-25 12:53:06 -05:00
Jaime Baez
cca2f7d178 Fix web container port mapping (#264)
Vite uses port 3000 to do Websocket connection. If it doesn't mange to
connect it enters in an endless loop refreshing the page.
2022-06-25 12:30:38 -05:00
Matthias Rupp
baf533de35 Delete assets from server if local deletion fails (#260)
* Delete assets from server if local deletion fails

* Remove commented line
2022-06-24 10:02:09 -05:00
Alex
dfc0d6eee7 Update readme with additional image 2022-06-24 07:28:56 -05:00
Alex Tran
7948cb8110 remove redirect from previous implementation of web routing 2022-06-24 06:33:11 -05:00
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
244 changed files with 5195 additions and 3469 deletions

View File

@@ -91,3 +91,30 @@ jobs:
push: true
tags: |
altran1502/immich-web:latest
build_and_push_nginx_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Proxy
uses: docker/build-push-action@v3.0.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: true
tags: |
altran1502/immich-proxy:latest

View File

@@ -93,3 +93,30 @@ jobs:
push: ${{ github.event_name == 'pull_request' }}
tags: |
altran1502/immich-web:staging
build_and_push_nginx_staging:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Proxy
uses: docker/build-push-action@v3.0.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name == 'pull_request' }}
tags: |
altran1502/immich-proxy:staging

View File

@@ -43,6 +43,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-server:${{ steps.previoustag.outputs.tag }}
altran1502/immich-server:release
build_and_push_machine_learning_release:
runs-on: ubuntu-latest
@@ -75,6 +76,7 @@ jobs:
push: true
tags: |
altran1502/immich-machine-learning:${{ steps.previoustag.outputs.tag }}
altran1502/immich-machine-learning:release
build_and_push_web_release:
runs-on: ubuntu-latest
@@ -114,3 +116,43 @@ jobs:
target: prod
tags: |
altran1502/immich-web:${{ steps.previoustag.outputs.tag }}
altran1502/immich-web:release
build_and_push_nginx_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
fetch-depth: 0
- name: "Get Previous tag"
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-proxy release
uses: docker/build-push-action@v3.0.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-proxy:release
altran1502/immich-proxy:${{ steps.previoustag.outputs.tag }}

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

@@ -8,9 +8,9 @@
<img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/>
</a>
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Server Docker&logo=docker&labelColor=ececec" />
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" />
</a>
<a href="https://discord.gg/rxnyVTXGbM">
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
</a>
<br/>
@@ -25,7 +25,7 @@
# 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)
@@ -33,7 +33,7 @@ Loading ~4000 images/videos
## Screenshots
### Mobile client
### Mobile
<p align="left">
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
<img src="design/backup-screen.png" width="150" title="Backup Setting Info">
@@ -44,9 +44,10 @@ Loading ~4000 images/videos
<img src="design/nsc6.png" width="150" title="EXIF Info">
</p>
### Web client
<p align="center">
<img src="design/dashboard_photos.jpeg" width="100%" title="Home Dashboard">
### Web
<p align="left">
<img src="design/web-home.jpeg" width="49%" title="Home Dashboard">
<img src="design/web-detail.jpeg" width="49%" title="Detail">
</p>
# Note
@@ -55,26 +56,22 @@ Loading ~4000 images/videos
This project is under heavy development, there will be continuous functions, features and api changes.
# Features
# Features
| | Mobile | Web |
| - | - | - |
| Upload and view videos and photos | Yes | Yes
| Auto backup when app is opened | Yes | N/A
| Selective album(s) for backup | Yes | N/A
| Download photos and videos to local device | Yes | Yes
| Multi-user support | Yes | Yes
| Shared Albums | Yes | No
| Quick navigation with draggable scrollbar | Yes | Yes
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
| Metadata view (EXIF, map) | Yes | Yes
| Search by metadata, objects and image tags | Yes | No
| Administrative functions (user management) | No | Yes
- Upload and view assets (videos/images).
- Auto Backup.
- Download asset to local device.
- Multi-user supported.
- Quick navigation with drag scroll bar.
- Support HEIC/HEIF Backup.
- Extract and display EXIF info.
- Real-time render from multi-device upload event.
- Image Tagging/Classification based on ImageNet dataset
- Object detection based on COCO SSD.
- Search assets based on tags and exif data (lens, make, model, orientation)
- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
- Show asset's location information on map (OpenStreetMap).
- Show curated places on the search page
- Show curated objects on the search page
- Shared album with users on the same server
- Selective backup - albums can be included and excluded during the backup process.
- Web interface is available for administrative tasks (creating new users) and viewing assets on the server - additional features are coming.
# System Requirement
@@ -97,7 +94,7 @@ You can use docker compose for development and testing out the application, ther
3. **PostgreSQL** - Main database of the application
4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
5. **Nginx** - Load balancing and optimized file uploading.
6. **TensorFlow** - Object Detection and Image Classification.
6. **TensorFlow** - Object Detection (COCO SSD) and Image Classification (ImageNet).
## Step 1: Populate .env file
@@ -146,8 +143,8 @@ MAPBOX_KEY=
# This is the URL of your vm/server where you host Immich, so that the web frontend
# know where can it make the request to.
# For example: If your server IP address is 10.1.11.50, the environment variable will
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283
VITE_SERVER_ENDPOINT=http://192.168.1.216:2283
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283/api
VITE_SERVER_ENDPOINT=http://192.168.1.216:2283/api
```
## Step 2: Start the server
@@ -170,11 +167,11 @@ To *update* docker-compose with newest image (if you have started the docker-com
docker-compose -f ./docker/docker-compose.yml pull && docker-compose -f ./docker/docker-compose.yml up
```
The server will be running at `http://your-ip:2283` through `Nginx`
The server will be running at `http://your-ip:2283/api` through `Nginx`
## Step 3: Register User
Access the web interface at `http://your-ip:2285` to register an admin account.
Access the web interface at `http://your-ip:2283` to register an admin account.
<p align="left">
<img src="design/admin-registration-form.png" width="300" title="Admin Registration">
@@ -188,7 +185,11 @@ Additional accounts on the server can be created by the admin account.
## Step 4: Run mobile app
The app is distributed on several platforms below.
Login the mobile app with your server address
<p align="left">
<img src="design/login-screen.jpeg" width="250" title="Example login screen">
<p/>
## F-Droid
You can get the app on F-droid by clicking the image below.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

BIN
design/login-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 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
# know where can it make the request to.
# For example: If your server IP address is 10.1.11.50, the environment variable will
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283/api
# !CAUTION! THERE IS NO FORWARD SLASH AT THE END
VITE_SERVER_ENDPOINT=

View File

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

View File

@@ -2,13 +2,11 @@ version: "3.8"
services:
immich-server:
image: immich-server-dev:1.9.0
image: immich-server-dev:latest
build:
context: ../server
dockerfile: Dockerfile
command: npm run start:dev immich
expose:
- "3000"
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -20,17 +18,13 @@ services:
depends_on:
- redis
- database
networks:
- immich-network
immich-machine-learning:
image: immich-machine-learning-dev:1.9.0
image: immich-machine-learning-dev:latest
build:
context: ../machine-learning
dockerfile: Dockerfile
command: npm run start:dev
expose:
- "3001"
volumes:
- ../machine-learning:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -41,11 +35,9 @@ services:
- NODE_ENV=development
depends_on:
- database
networks:
- immich-network
immich-microservices:
image: immich-microservices:1.9.0
image: immich-microservices:latest
build:
context: ../server
dockerfile: Dockerfile
@@ -60,8 +52,7 @@ services:
- NODE_ENV=development
depends_on:
- database
networks:
- immich-network
- immich-server
immich-web:
image: immich-web-dev:1.9.0
@@ -73,20 +64,16 @@ services:
env_file:
- .env
ports:
- 3002:3002
- 3000:3000
- 24678:24678
volumes:
- ../web:/usr/src/app
- /usr/src/app/node_modules
networks:
- immich-network
restart: always
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich-network
database:
container_name: immich_postgres
@@ -102,25 +89,21 @@ services:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich-network
nginx:
container_name: proxy_nginx
image: nginx:latest
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
immich-proxy:
container_name: immich_proxy
image: immich-proxy-dev:latest
build:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:80
- 2284:443
logging:
driver: none
networks:
- immich-network
depends_on:
- immich-server
restart: always
networks:
immich-network:
volumes:
pgdata:

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:
image: altran1502/immich-server:staging
entrypoint: ["/bin/sh", "./start-server.sh"]
expose:
- "3000"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -15,8 +13,6 @@ services:
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-microservices:
@@ -31,15 +27,11 @@ services:
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-machine-learning:
image: altran1502/immich-machine-learning:staging
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -48,8 +40,6 @@ services:
- NODE_ENV=production
depends_on:
- database
networks:
- immich-network
restart: always
immich-web:
@@ -57,17 +47,11 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
ports:
- 2285:3000
networks:
- immich-network
restart: always
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich-network
restart: always
database:
@@ -82,29 +66,19 @@ services:
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich-network
restart: always
nginx:
container_name: proxy_nginx
image: nginx:latest
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
immich-proxy:
container_name: immich_proxy
image: altran1502/immich-proxy:staging
ports:
- 2283:80
- 2284:443
logging:
driver: none
networks:
- immich-network
depends_on:
- immich-server
restart: always
networks:
immich-network:
volumes:
pgdata:

View File

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

View File

@@ -2,10 +2,8 @@ version: "3.8"
services:
immich-server:
image: altran1502/immich-server:latest
image: altran1502/immich-server:release
entrypoint: ["/bin/sh", "./start-server.sh"]
expose:
- "3000"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -15,12 +13,10 @@ services:
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-microservices:
image: altran1502/immich-server:latest
image: altran1502/immich-server:release
entrypoint: ["/bin/sh", "./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -31,15 +27,11 @@ services:
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-machine-learning:
image: altran1502/immich-machine-learning:latest
image: altran1502/immich-machine-learning:release
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -48,26 +40,18 @@ services:
- NODE_ENV=production
depends_on:
- database
networks:
- immich-network
restart: always
immich-web:
image: altran1502/immich-web:latest
image: altran1502/immich-web:release
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
ports:
- 2285:3000
networks:
- immich-network
restart: always
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich-network
restart: always
database:
@@ -82,29 +66,19 @@ services:
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich-network
restart: always
nginx:
container_name: proxy_nginx
image: nginx:latest
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
immich-proxy:
container_name: immich_proxy
image: altran1502/immich-proxy:release
ports:
- 2283:80
- 2284:443
logging:
driver: none
networks:
- immich-network
depends_on:
- immich-server
restart: always
networks:
immich-network:
volumes:
pgdata:

View File

@@ -1,46 +0,0 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# events {
# worker_connections 1000;
# }
server {
gzip on;
gzip_min_length 1000;
gunzip on;
client_max_body_size 50000M;
listen 80;
access_log off;
location / {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich-server:3000;
}
}

View File

@@ -1,10 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
# This file should be version controlled.
version:
revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b
revision: cd41fdd495f6944ecd3506c21e94c6567b073278
channel: stable
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
- platform: android
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
- platform: ios
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
- platform: linux
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
- platform: macos
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
- platform: web
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
- platform: windows
create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

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

View File

@@ -81,5 +81,4 @@ flutter {
dependencies {
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 {
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"
}
}

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

@@ -0,0 +1,3 @@
* Fixed app does not resume back up when reopening a closed app
* Fixed wrong asset count on the upload page
* Added mechanism to change the password of new user on the first login (except Admin)

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,22 +28,26 @@ class ImageViewerPageState {
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
return ImageViewerPageState(
downloadAssetStatus: DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
downloadAssetStatus:
DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
);
}
String toJson() => json.encode(toMap());
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
factory ImageViewerPageState.fromJson(String source) =>
ImageViewerPageState.fromMap(json.decode(source));
@override
String toString() => 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
String toString() =>
'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImageViewerPageState && other.downloadAssetStatus == downloadAssetStatus;
return other is ImageViewerPageState &&
other.downloadAssetStatus == downloadAssetStatus;
}
@override

View File

@@ -9,7 +9,9 @@ import 'package:immich_mobile/shared/ui/immich_toast.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService = ImageViewerService();
ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle));
ImageViewerStateNotifier()
: super(ImageViewerPageState(
downloadAssetStatus: DownloadAssetStatus.idle));
void downloadAsset(ImmichAsset asset, BuildContext context) async {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
@@ -40,4 +42,5 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
}
final imageViewerStateProvider =
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(((ref) => ImageViewerStateNotifier()));
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerStateNotifier()));

View File

@@ -15,12 +15,14 @@ class ImageViewerService {
try {
String fileName = p.basename(asset.originalPath);
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
Uri filePath =
Uri.parse("$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
Uri filePath = Uri.parse(
"$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
var res = await http.get(
filePath,
headers: {"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"},
headers: {
"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"
},
);
final AssetEntity? entity;

View File

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

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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -5,7 +7,10 @@ import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
const TopControlAppBar(
{Key? key, required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed})
{Key? key,
required this.asset,
required this.onMoreInfoPressed,
required this.onDownloadPressed})
: super(key: key);
final ImmichAsset asset;
@@ -42,9 +47,11 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("favorite");
log("favorite");
},
icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
icon: asset.isFavorite
? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_border_rounded),
),
IconButton(
iconSize: iconSize,

View File

@@ -1,8 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -10,6 +8,7 @@ import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_stat
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
@@ -21,20 +20,26 @@ class ImageViewerPage extends HookConsumerWidget {
final String heroTag;
final String thumbnailUrl;
final ImmichAsset asset;
final AssetService _assetService = AssetService();
ImmichAssetWithExif? assetDetail;
ImageViewerPage(
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
: super(key: key);
ImageViewerPage({
Key? key,
required this.imageUrl,
required this.heroTag,
required this.thumbnailUrl,
required this.asset,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
var box = Hive.box(userInfoBox);
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
assetDetail =
await ref.watch(assetServiceProvider).getAssetById(asset.id);
}
showInfo() {
@@ -60,75 +65,30 @@ class ImageViewerPage extends HookConsumerWidget {
asset: asset,
onMoreInfoPressed: showInfo,
onDownloadPressed: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
ref
.watch(imageViewerStateProvider.notifier)
.downloadAsset(asset, context);
},
),
body: SwipeDetector(
onSwipeDown: (_) {
AutoRouter.of(context).pop();
},
onSwipeUp: (_) {
showInfo();
},
child: SafeArea(
child: Stack(
children: [
Center(
child: Hero(
body: SafeArea(
child: Stack(
children: [
Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
child: RemotePhotoView(
thumbnailUrl: thumbnailUrl,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Wrap(
spacing: 32,
runSpacing: 32,
alignment: WrapAlignment.center,
children: [
const Text(
"Failed To Render Image - Possibly Corrupted Data",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.white),
),
SingleChildScrollView(
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
),
],
),
),
placeholder: (context, url) {
return CachedNetworkImage(
cacheKey: thumbnailUrl,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => Icon(
Icons.error,
color: Colors.grey[300],
),
);
},
),
),
authToken: "Bearer ${box.get(accessTokenKey)}",
onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(),
)),
),
if (downloadAssetStatus == DownloadAssetStatus.loading)
const Center(
child: DownloadLoadingIndicator(),
),
if (downloadAssetStatus == DownloadAssetStatus.loading)
const Center(
child: DownloadLoadingIndicator(),
),
],
),
],
),
),
);

View File

@@ -21,13 +21,14 @@ class VideoViewerPage extends HookConsumerWidget {
final String videoUrl;
final ImmichAsset asset;
ImmichAssetWithExif? assetDetail;
final AssetService _assetService = AssetService();
VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) : super(key: key);
VideoViewerPage({Key? key, required this.videoUrl, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
@@ -44,7 +45,8 @@ class VideoViewerPage extends HookConsumerWidget {
}
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
assetDetail =
await ref.watch(assetServiceProvider).getAssetById(asset.id);
}
useEffect(() {
@@ -60,7 +62,9 @@ class VideoViewerPage extends HookConsumerWidget {
showInfo();
},
onDownloadPressed: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
ref
.watch(imageViewerStateProvider.notifier)
.downloadAsset(asset, context);
},
),
body: SwipeDetector(
@@ -93,7 +97,8 @@ class VideoThumbnailPlayer extends StatefulWidget {
final String url;
final String? jwtToken;
const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) : super(key: key);
const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken})
: super(key: key);
@override
State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
@@ -111,8 +116,8 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
Future<void> initializePlayer() async {
try {
videoPlayerController =
VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"});
videoPlayerController = VideoPlayerController.network(widget.url,
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"});
await videoPlayerController.initialize();
_createChewieController();
@@ -142,7 +147,8 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
@override
Widget build(BuildContext context) {
return chewieController != null && chewieController!.videoPlayerController.value.isInitialized
return chewieController != null &&
chewieController!.videoPlayerController.value.isInitialized
? SizedBox(
child: Chewie(
controller: chewieController!,

View File

@@ -21,13 +21,16 @@ class AvailableAlbum {
}
@override
String toString() => 'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AvailableAlbum && other.albumEntity == albumEntity && other.thumbnailData == thumbnailData;
return other is AvailableAlbum &&
other.albumEntity == albumEntity &&
other.thumbnailData == thumbnailData;
}
@override

View File

@@ -10,7 +10,7 @@ enum BackUpProgressEnum { idle, inProgress, done }
class BackUpState extends Equatable {
// enum
final BackUpProgressEnum backupProgress;
final List<String> allAssetOnDatabase;
final List<String> allAssetsInDatabase;
final double progressInPercentage;
final CancellationToken cancelToken;
final ServerInfo serverInfo;
@@ -28,7 +28,7 @@ class BackUpState extends Equatable {
const BackUpState({
required this.backupProgress,
required this.allAssetOnDatabase,
required this.allAssetsInDatabase,
required this.progressInPercentage,
required this.cancelToken,
required this.serverInfo,
@@ -41,7 +41,7 @@ class BackUpState extends Equatable {
BackUpState copyWith({
BackUpProgressEnum? backupProgress,
List<String>? allAssetOnDatabase,
List<String>? allAssetsInDatabase,
double? progressInPercentage,
CancellationToken? cancelToken,
ServerInfo? serverInfo,
@@ -53,7 +53,7 @@ class BackUpState extends Equatable {
}) {
return BackUpState(
backupProgress: backupProgress ?? this.backupProgress,
allAssetOnDatabase: allAssetOnDatabase ?? this.allAssetOnDatabase,
allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase,
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
@@ -61,20 +61,21 @@ class BackUpState extends Equatable {
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
selectedAlbumsBackupAssetsIds:
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
);
}
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetOnDatabase: $allAssetOnDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)';
}
@override
List<Object> get props {
return [
backupProgress,
allAssetOnDatabase,
allAssetsInDatabase,
progressInPercentage,
cancelToken,
serverInfo,

View File

@@ -19,7 +19,8 @@ class HiveBackupAlbums {
});
@override
String toString() => 'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)';
String toString() =>
'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)';
HiveBackupAlbums copyWith({
List<String>? selectedAlbumIds,
@@ -49,7 +50,8 @@ class HiveBackupAlbums {
String toJson() => json.encode(toMap());
factory HiveBackupAlbums.fromJson(String source) => HiveBackupAlbums.fromMap(json.decode(source));
factory HiveBackupAlbums.fromJson(String source) =>
HiveBackupAlbums.fromMap(json.decode(source));
@override
bool operator ==(Object other) {

View File

@@ -1,24 +1,24 @@
import 'package:cancellation_token_http/http.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier({this.ref})
BackupNotifier(this._backupService, this._serverInfoService, this._authState)
: super(
BackUpState(
backupProgress: BackUpProgressEnum.idle,
allAssetOnDatabase: const [],
allAssetsInDatabase: const [],
progressInPercentage: 0,
cancelToken: CancellationToken(),
serverInfo: ServerInfo(
@@ -36,11 +36,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {},
),
);
) {
getBackupInfo();
}
Ref? ref;
final BackupService _backupService = BackupService();
final ServerInfoService _serverInfoService = ServerInfoService();
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final AuthenticationState _authState;
///
/// UI INTERACTION
@@ -55,7 +57,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
removeExcludedAlbumForBackup(album);
}
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
state = state
.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
_updateBackupAssetCount();
}
@@ -63,7 +66,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album);
}
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
state = state
.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
_updateBackupAssetCount();
}
@@ -91,19 +95,22 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (Recent on Android, Recents on iOS)
///
Future<void> getBackupAlbumsInfo() async {
Future<void> _getBackupAlbumsInfo() async {
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(hasAll: true, type: RequestType.common);
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true, type: RequestType.common);
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
var assetList = await album.getAssetListRange(start: 0, end: album.assetCount);
var assetList =
await album.getAssetListRange(start: 0, end: album.assetCount);
if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first;
var thumbnailData = await thumbnailAsset.thumbnailDataWithSize(const ThumbnailSize(512, 512));
var thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
}
@@ -114,7 +121,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Put persistent storage info into local state of the app
// Get local storage on selected backup album
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
Box<HiveBackupAlbums> backupAlbumInfoBox =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
backupInfoKey,
defaultValue: HiveBackupAlbums(
@@ -133,7 +141,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
debugPrint("First time backup setup recent album as default");
// Get album that contains all assets
var list = await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
var list = await PhotoManager.getAssetPathList(
hasAll: true, onlyAll: true, type: RequestType.common);
AssetPathEntity albumHasAllAssets = list.first;
backupAlbumInfoBox.put(
@@ -151,12 +160,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
try {
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset});
state = state.copyWith(
selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset});
}
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset});
state = state.copyWith(
excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset});
}
} catch (e) {
debugPrint("[ERROR] Failed to generate album from id $e");
@@ -168,39 +179,45 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Find the assets that are not overlapping between the two sets
/// Those assets are unique and are used as the total assets
///
void _updateBackupAssetCount() async {
Future<void> _updateBackupAssetCount() async {
Set<AssetEntity> assetsFromSelectedAlbums = {};
Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) {
var assets = await album.getAssetListRange(start: 0, end: album.assetCount);
var assets =
await album.getAssetListRange(start: 0, end: album.assetCount);
assetsFromSelectedAlbums.addAll(assets);
}
for (var album in state.excludedBackupAlbums) {
var assets = await album.getAssetListRange(start: 0, end: album.assetCount);
var assets =
await album.getAssetListRange(start: 0, end: album.assetCount);
assetsFromExcludedAlbums.addAll(assets);
}
Set<AssetEntity> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
List<String> allAssetOnDatabase = await _backupService.getDeviceBackupAsset();
Set<AssetEntity> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
List<String> allAssetsInDatabase =
await _backupService.getDeviceBackupAsset();
// Find asset that were backup from selected albums
Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId));
Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetOnDatabase: allAssetOnDatabase,
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: {},
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
return;
} else {
state = state.copyWith(
allAssetOnDatabase: allAssetOnDatabase,
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
@@ -208,6 +225,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Save to persistent storage
_updatePersistentAlbumsSelection();
return;
}
///
@@ -215,10 +234,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// which albums are selected or excluded
/// and then update the UI according to those information
///
void getBackupInfo() async {
await getBackupAlbumsInfo();
_updateServerInfo();
_updateBackupAssetCount();
Future<void> getBackupInfo() async {
await _getBackupAlbumsInfo();
await _updateServerInfo();
await _updateBackupAssetCount();
}
///
@@ -226,7 +245,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Hive database
///
void _updatePersistentAlbumsSelection() {
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
Box<HiveBackupAlbums> backupAlbumInfoBox =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
backupAlbumInfoBox.put(
backupInfoKey,
HiveBackupAlbums(
@@ -240,11 +260,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Invoke backup process
///
void startBackupProcess() async {
_updateServerInfo();
_updateBackupAssetCount();
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
await getBackupInfo();
var authResult = await PhotoManager.requestPermissionExtend();
if (authResult.isAuth) {
await PhotoManager.clearFileCache();
@@ -255,10 +274,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return;
}
Set<AssetEntity> assetsWillBeBackup = state.allUniqueAssets;
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (var assetId in state.allAssetOnDatabase) {
for (var assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
}
@@ -268,7 +287,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress);
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken,
_onAssetUploaded, _onUploadProgress);
} else {
PhotoManager.openSetting();
}
@@ -276,26 +296,35 @@ class BackupNotifier extends StateNotifier<BackUpState> {
void cancelBackup() {
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) {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, deviceAssetId},
allAssetOnDatabase: [...state.allAssetOnDatabase, deviceAssetId]);
state = state.copyWith(selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId
}, allAssetsInDatabase: [
...state.allAssetsInDatabase,
deviceAssetId
]);
if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) {
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
0) {
state = state.copyWith(
backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
}
_updateServerInfo();
}
void _onUploadProgress(int sent, int total) {
state = state.copyWith(progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
state = state.copyWith(
progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
}
void _updateServerInfo() async {
Future<void> _updateServerInfo() async {
var serverInfo = await _serverInfoService.getServerInfo();
// Update server info
@@ -313,36 +342,38 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
void resumeBackup() {
var authState = ref?.read(authenticationProvider);
// Check if user is login
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
// User has been logged out return
if (authState != null) {
if (accessKey == null || !authState.isAuthenticated) {
debugPrint("[resumeBackup] not authenticated - abort");
if (accessKey == null || !_authState.isAuthenticated) {
debugPrint("[resumeBackup] not authenticated - abort");
return;
}
// Check if this device is enable backup by the user
if ((_authState.deviceInfo.deviceId == _authState.deviceId) &&
_authState.deviceInfo.isAutoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
return;
}
// Check if this device is enable backup by the user
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
startBackupProcess();
}
return;
// Run backup
debugPrint("[resumeBackup] Start back up");
startBackupProcess();
}
return;
}
}
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(ref: ref);
final backupProvider =
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider),
);
});

View File

@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart';
@@ -14,20 +15,28 @@ import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http;
final backupServiceProvider =
Provider((ref) => BackupService(ref.watch(networkServiceProvider)));
class BackupService {
final NetworkService _networkService = NetworkService();
final NetworkService _networkService;
BackupService(this._networkService);
Future<List<String>> getDeviceBackupAsset() async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
Response response = await _networkService.getRequest(url: "asset/$deviceId");
Response response =
await _networkService.getRequest(url: "asset/$deviceId");
List<dynamic> result = jsonDecode(response.toString());
return result.cast<String>();
}
backupAsset(Set<AssetEntity> assetList, http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgress) async {
backupAsset(
Set<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgress) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file;
@@ -44,7 +53,8 @@ class BackupService {
if (file != null) {
String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0];
String fileNameWithoutPath =
originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path);
var mimeType = FileHelper.getMimeType(file.path);
var fileStream = file.openRead();
@@ -60,7 +70,8 @@ class BackupService {
);
// 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) {
thumbnailUploadData = http.MultipartFile.fromBytes(
"thumbnailData",
@@ -75,8 +86,10 @@ class BackupService {
var box = Hive.box(userInfoBox);
var req = MultipartRequest('POST', Uri.parse('$savedEndpoint/asset/upload'),
onProgress: ((bytes, totalBytes) => uploadProgress(bytes, totalBytes)));
var req = MultipartRequest(
'POST', Uri.parse('$savedEndpoint/asset/upload'),
onProgress: ((bytes, totalBytes) =>
uploadProgress(bytes, totalBytes)));
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
req.fields['deviceAssetId'] = entity.id;
@@ -126,7 +139,8 @@ class BackupService {
}
}
Future<DeviceInfoRemote> setAutoBackup(bool status, String deviceId, String deviceType) async {
Future<DeviceInfoRemote> setAutoBackup(
bool status, String deviceId, String deviceType) async {
var res = await _networkService.patchRequest(url: 'device-info', data: {
"isAutoBackup": status,
"deviceId": deviceId,

View File

@@ -14,16 +14,22 @@ class AlbumInfoCard extends HookConsumerWidget {
final Uint8List? imageData;
final AssetPathEntity albumInfo;
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo}) : super(key: key);
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
final bool isSelected =
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
ColorFilter selectedFilter = ColorFilter.mode(Theme.of(context).primaryColor.withAlpha(100), BlendMode.darken);
ColorFilter excludedFilter = ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter = const ColorFilter.mode(Colors.black, BlendMode.color);
ColorFilter selectedFilter = ColorFilter.mode(
Theme.of(context).primaryColor.withAlpha(100), BlendMode.darken);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
_buildSelectedTextBox() {
if (isSelected) {
@@ -32,7 +38,8 @@ class AlbumInfoCard extends HookConsumerWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text(
"INCLUDED",
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: Theme.of(context).primaryColor,
);
@@ -42,7 +49,8 @@ class AlbumInfoCard extends HookConsumerWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text(
"EXCLUDED",
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: Colors.red[300],
);
@@ -85,10 +93,15 @@ class AlbumInfoCard extends HookConsumerWidget {
HapticFeedback.selectionClick();
if (isExcluded) {
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(albumInfo);
ref
.watch(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 &&
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo)) {
ref
.watch(backupProvider)
.selectedBackupAlbums
.contains(albumInfo)) {
ImmichToast.show(
context: context,
msg: "Cannot exclude the only album",
@@ -98,7 +111,9 @@ class AlbumInfoCard extends HookConsumerWidget {
return;
}
ref.watch(backupProvider.notifier).addExcludedAlbumForBackup(albumInfo);
ref
.watch(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
}
},
child: Card(
@@ -121,12 +136,16 @@ class AlbumInfoCard extends HookConsumerWidget {
width: 200,
height: 200,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12)),
image: DecorationImage(
colorFilter: _buildImageFilter(),
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage('assets/immich-logo-no-outline.png') as ImageProvider,
: const AssetImage(
'assets/immich-logo-no-outline.png')
as ImageProvider,
fit: BoxFit.cover,
),
),
@@ -150,13 +169,17 @@ class AlbumInfoCard extends HookConsumerWidget {
Text(
albumInfo.name,
style: TextStyle(
fontSize: 14, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
fontSize: 14,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
albumInfo.assetCount.toString() + (albumInfo.isAll ? " (ALL)" : ""),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
albumInfo.assetCount.toString() +
(albumInfo.isAll ? " (ALL)" : ""),
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
),
)
],
@@ -165,7 +188,8 @@ class AlbumInfoCard extends HookConsumerWidget {
),
IconButton(
onPressed: () {
AutoRouter.of(context).push(AlbumPreviewRoute(album: albumInfo));
AutoRouter.of(context)
.push(AlbumPreviewRoute(album: albumInfo));
},
icon: Icon(
Icons.image_outlined,

View File

@@ -4,7 +4,12 @@ class BackupInfoCard extends StatelessWidget {
final String title;
final String subtitle;
final String info;
const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key);
const BackupInfoCard(
{Key? key,
required this.title,
required this.subtitle,
required this.info})
: super(key: key);
@override
Widget build(BuildContext context) {

View File

@@ -16,7 +16,8 @@ class AlbumPreviewPage extends HookConsumerWidget {
final assets = useState<List<AssetEntity>>([]);
_getAssetsInAlbum() async {
assets.value = await album.getAssetListRange(start: 0, end: album.assetCount);
assets.value =
await album.getAssetListRange(start: 0, end: album.assetCount);
}
useEffect(() {
@@ -37,7 +38,10 @@ class AlbumPreviewPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 4.0),
child: Text(
"ID ${album.id}",
style: TextStyle(fontSize: 10, color: Colors.grey[600], fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
fontWeight: FontWeight.bold),
),
),
],
@@ -55,8 +59,9 @@ class AlbumPreviewPage extends HookConsumerWidget {
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
Future<Uint8List?> thumbData =
assets.value[index].thumbnailDataWithSize(const ThumbnailSize(200, 200), quality: 50);
Future<Uint8List?> thumbData = assets.value[index]
.thumbnailDataWithSize(const ThumbnailSize(200, 200),
quality: 50);
return FutureBuilder<Uint8List?>(
future: thumbData,

View File

@@ -17,7 +17,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
useEffect(() {
ref.read(backupProvider.notifier).getBackupAlbumsInfo();
ref.read(backupProvider.notifier).getBackupInfo();
return null;
}, []);
@@ -37,8 +37,12 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
itemBuilder: ((context, index) {
var thumbnailData = availableAlbums[index].thumbnailData;
return Padding(
padding: index == 0 ? const EdgeInsets.only(left: 16.00) : const EdgeInsets.all(0),
child: AlbumInfoCard(imageData: thumbnailData, albumInfo: availableAlbums[index].albumEntity),
padding: index == 0
? const EdgeInsets.only(left: 16.00)
: const EdgeInsets.all(0),
child: AlbumInfoCard(
imageData: thumbnailData,
albumInfo: availableAlbums[index].albumEntity),
);
}),
),
@@ -67,10 +71,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
onTap: removeSelection,
child: Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
label: Text(
album.name,
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold),
),
backgroundColor: Theme.of(context).primaryColor,
deleteIconColor: Colors.white,
@@ -88,7 +96,9 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
_buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) {
void removeSelection() {
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album);
ref
.watch(backupProvider.notifier)
.removeExcludedAlbumForBackup(album);
}
return GestureDetector(
@@ -97,10 +107,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
label: Text(
album.name,
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold),
),
backgroundColor: Colors.red[300],
deleteIconColor: Colors.white,
@@ -142,7 +156,10 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [..._buildSelectedAlbumNameChip(), ..._buildExcludedAlbumNameChip()],
children: [
..._buildSelectedAlbumNameChip(),
..._buildExcludedAlbumNameChip()
],
),
),
@@ -165,10 +182,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
visualDensity: VisualDensity.compact,
title: Text(
"Total unique assets",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.grey[700]),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.grey[700]),
),
trailing: Text(
ref.watch(backupProvider).allUniqueAssets.length.toString(),
ref
.watch(backupProvider)
.allUniqueAssets
.length
.toString(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
@@ -206,7 +230,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
elevation: 5,
title: Text(
'Selection Info',
@@ -221,7 +246,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
children: [
Text(
'Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.',
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
style: TextStyle(
fontSize: 14, color: Colors.grey[700]),
),
],
),

View File

@@ -17,16 +17,21 @@ class BackupControllerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
bool shouldBackup =
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
AuthenticationState authenticationState = ref.watch(authenticationProvider);
bool shouldBackup = backupState.allUniqueAssets.length -
backupState.selectedAlbumsBackupAssetsIds.length ==
0
? false
: true;
useEffect(() {
if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
ref.read(backupProvider.notifier).getBackupInfo();
ref.watch(backupProvider.notifier).getBackupInfo();
}
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success');
ref
.watch(websocketProvider.notifier)
.stopListenToEvent('on_upload_success');
return null;
}, []);
@@ -48,7 +53,8 @@ class BackupControllerPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearPercentIndicator(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 6.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
@@ -58,7 +64,8 @@ class BackupControllerPage extends HookConsumerWidget {
),
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Text('${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'),
child: Text(
'${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'),
),
],
),
@@ -67,9 +74,11 @@ class BackupControllerPage extends HookConsumerWidget {
}
ListTile _buildBackupController() {
var backUpOption = _authenticationState.deviceInfo.isAutoBackup ? "on" : "off";
var isAutoBackup = _authenticationState.deviceInfo.isAutoBackup;
var backupBtnText = _authenticationState.deviceInfo.isAutoBackup ? "off" : "on";
var backUpOption =
authenticationState.deviceInfo.isAutoBackup ? "on" : "off";
var isAutoBackup = authenticationState.deviceInfo.isAutoBackup;
var backupBtnText =
authenticationState.deviceInfo.isAutoBackup ? "off" : "on";
return ListTile(
isThreeLine: true,
leading: isAutoBackup
@@ -103,11 +112,18 @@ class BackupControllerPage extends HookConsumerWidget {
),
),
onPressed: () {
isAutoBackup
? ref.watch(authenticationProvider.notifier).setAutoBackup(false)
: ref.watch(authenticationProvider.notifier).setAutoBackup(true);
if (isAutoBackup) {
ref
.read(authenticationProvider.notifier)
.setAutoBackup(false);
} else {
ref
.read(authenticationProvider.notifier)
.setAutoBackup(true);
}
},
child: Text("Turn $backupBtnText Backup", style: const TextStyle(fontWeight: FontWeight.bold)),
child: Text("Turn $backupBtnText Backup",
style: const TextStyle(fontWeight: FontWeight.bold)),
),
)
],
@@ -133,7 +149,10 @@ class BackupControllerPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 12,
fontWeight: FontWeight.bold),
),
);
} else {
@@ -141,7 +160,10 @@ class BackupControllerPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"None selected",
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 12,
fontWeight: FontWeight.bold),
),
);
}
@@ -160,7 +182,10 @@ class BackupControllerPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: TextStyle(color: Colors.red[300], fontSize: 12, fontWeight: FontWeight.bold),
style: TextStyle(
color: Colors.red[300],
fontSize: 12,
fontWeight: FontWeight.bold),
),
);
} else {
@@ -181,14 +206,15 @@ class BackupControllerPage extends HookConsumerWidget {
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 15,
title: const Text("Backup Albums", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
title: const Text("Backup Albums",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Albums to be backup",
"Albums to be backed up",
style: TextStyle(color: Color(0xFF808080), fontSize: 12),
),
_buildSelectedAlbumName(),
@@ -258,13 +284,14 @@ class BackupControllerPage extends HookConsumerWidget {
),
BackupInfoCard(
title: "Backup",
subtitle: "Photos and videos from selected albums that are backup",
subtitle: "Backed up photos and videos",
info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
),
BackupInfoCard(
title: "Remainder",
subtitle: "Photos and videos that has not been backing up from selected albums",
info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
subtitle: "Remaining photos and albums to back up from selection",
info:
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
),
const Divider(),
_buildBackupController(),
@@ -289,29 +316,32 @@ class BackupControllerPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
child: backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Colors.red[300],
onPrimary: Colors.grey[50],
),
onPressed: () {
ref.read(backupProvider.notifier).cancelBackup();
},
child: const Text("Cancel"),
)
: ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
),
onPressed: shouldBackup
? () {
ref.read(backupProvider.notifier).startBackupProcess();
}
: null,
child: const Text("Start Backup"),
),
child:
backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Colors.red[300],
onPrimary: Colors.grey[50],
),
onPressed: () {
ref.read(backupProvider.notifier).cancelBackup();
},
child: const Text("Cancel"),
)
: ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
),
onPressed: shouldBackup
? () {
ref
.read(backupProvider.notifier)
.startBackupProcess();
}
: null,
child: const Text("Start Backup"),
),
),
)
],

View File

@@ -35,7 +35,8 @@ class DeleteAssetResponse {
String toJson() => json.encode(toMap());
factory DeleteAssetResponse.fromJson(String source) => DeleteAssetResponse.fromMap(json.decode(source));
factory DeleteAssetResponse.fromJson(String source) =>
DeleteAssetResponse.fromMap(json.decode(source));
@override
String toString() => 'DeleteAssetResponse(id: $id, status: $status)';
@@ -44,7 +45,9 @@ class DeleteAssetResponse {
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DeleteAssetResponse && other.id == id && other.status == status;
return other is DeleteAssetResponse &&
other.id == id &&
other.status == status;
}
@override

View File

@@ -31,13 +31,15 @@ class ImmichAssetGroupByDate {
factory ImmichAssetGroupByDate.fromMap(Map<String, dynamic> map) {
return ImmichAssetGroupByDate(
date: map['date'] ?? '',
assets: List<ImmichAsset>.from(map['assets']?.map((x) => ImmichAsset.fromMap(x))),
assets: List<ImmichAsset>.from(
map['assets']?.map((x) => ImmichAsset.fromMap(x))),
);
}
String toJson() => json.encode(toMap());
factory ImmichAssetGroupByDate.fromJson(String source) => ImmichAssetGroupByDate.fromMap(json.decode(source));
factory ImmichAssetGroupByDate.fromJson(String source) =>
ImmichAssetGroupByDate.fromMap(json.decode(source));
@override
String toString() => 'ImmichAssetGroupByDate(date: $date, assets: $assets)';
@@ -46,7 +48,9 @@ class ImmichAssetGroupByDate {
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichAssetGroupByDate && other.date == date && listEquals(other.assets, assets);
return other is ImmichAssetGroupByDate &&
other.date == date &&
listEquals(other.assets, assets);
}
@override
@@ -86,17 +90,20 @@ class GetAllAssetResponse {
factory GetAllAssetResponse.fromMap(Map<String, dynamic> map) {
return GetAllAssetResponse(
count: map['count']?.toInt() ?? 0,
data: List<ImmichAssetGroupByDate>.from(map['data']?.map((x) => ImmichAssetGroupByDate.fromMap(x))),
data: List<ImmichAssetGroupByDate>.from(
map['data']?.map((x) => ImmichAssetGroupByDate.fromMap(x))),
nextPageKey: map['nextPageKey'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory GetAllAssetResponse.fromJson(String source) => GetAllAssetResponse.fromMap(json.decode(source));
factory GetAllAssetResponse.fromJson(String source) =>
GetAllAssetResponse.fromMap(json.decode(source));
@override
String toString() => 'GetAllAssetResponse(count: $count, data: $data, nextPageKey: $nextPageKey)';
String toString() =>
'GetAllAssetResponse(count: $count, data: $data, nextPageKey: $nextPageKey)';
@override
bool operator ==(Object other) {

View File

@@ -37,14 +37,16 @@ class HomePageState {
factory HomePageState.fromMap(Map<String, dynamic> map) {
return HomePageState(
isMultiSelectEnable: map['isMultiSelectEnable'] ?? false,
selectedItems: Set<ImmichAsset>.from(map['selectedItems']?.map((x) => ImmichAsset.fromMap(x))),
selectedItems: Set<ImmichAsset>.from(
map['selectedItems']?.map((x) => ImmichAsset.fromMap(x))),
selectedDateGroup: Set<String>.from(map['selectedDateGroup']),
);
}
String toJson() => json.encode(toMap());
factory HomePageState.fromJson(String source) => HomePageState.fromMap(json.decode(source));
factory HomePageState.fromJson(String source) =>
HomePageState.fromMap(json.decode(source));
@override
String toString() =>
@@ -62,5 +64,8 @@ class HomePageState {
}
@override
int get hashCode => isMultiSelectEnable.hashCode ^ selectedItems.hashCode ^ selectedDateGroup.hashCode;
int get hashCode =>
isMultiSelectEnable.hashCode ^
selectedItems.hashCode ^
selectedDateGroup.hashCode;
}

View File

@@ -13,7 +13,8 @@ class HomePageStateNotifier extends StateNotifier<HomePageState> {
);
void addSelectedDateGroup(String dateGroupTitle) {
state = state.copyWith(selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle});
state = state.copyWith(
selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle});
}
void removeSelectedDateGroup(String dateGroupTitle) {
@@ -25,11 +26,13 @@ class HomePageStateNotifier extends StateNotifier<HomePageState> {
}
void enableMultiSelect(Set<ImmichAsset> selectedItems) {
state = state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems);
state =
state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems);
}
void disableMultiSelect() {
state = state.copyWith(isMultiSelectEnable: false, selectedItems: {}, selectedDateGroup: {});
state = state.copyWith(
isMultiSelectEnable: false, selectedItems: {}, selectedDateGroup: {});
}
void addSingleSelectedItem(ImmichAsset asset) {
@@ -60,4 +63,5 @@ class HomePageStateNotifier extends StateNotifier<HomePageState> {
}
final homePageStateProvider =
StateNotifierProvider<HomePageStateNotifier, HomePageState>(((ref) => HomePageStateNotifier()));
StateNotifierProvider<HomePageStateNotifier, HomePageState>(
((ref) => HomePageStateNotifier()));

View File

@@ -50,37 +50,46 @@ class UploadProfileImageState {
String toJson() => json.encode(toMap());
factory UploadProfileImageState.fromJson(String source) => UploadProfileImageState.fromMap(json.decode(source));
factory UploadProfileImageState.fromJson(String source) =>
UploadProfileImageState.fromMap(json.decode(source));
@override
String toString() => 'UploadProfileImageState(status: $status, profileImagePath: $profileImagePath)';
String toString() =>
'UploadProfileImageState(status: $status, profileImagePath: $profileImagePath)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UploadProfileImageState && other.status == status && other.profileImagePath == profileImagePath;
return other is UploadProfileImageState &&
other.status == status &&
other.profileImagePath == profileImagePath;
}
@override
int get hashCode => status.hashCode ^ profileImagePath.hashCode;
}
class UploadProfileImageNotifier extends StateNotifier<UploadProfileImageState> {
UploadProfileImageNotifier()
class UploadProfileImageNotifier
extends StateNotifier<UploadProfileImageState> {
UploadProfileImageNotifier(this._userSErvice)
: super(UploadProfileImageState(
profileImagePath: '',
status: UploadProfileStatus.idle,
));
final UserService _userSErvice;
Future<bool> upload(XFile file) async {
state = state.copyWith(status: UploadProfileStatus.loading);
var res = await UserService().uploadProfileImage(file);
var res = await _userSErvice.uploadProfileImage(file);
if (res != null) {
debugPrint("Succesfully upload profile image");
state = state.copyWith(status: UploadProfileStatus.success, profileImagePath: res.profileImagePath);
state = state.copyWith(
status: UploadProfileStatus.success,
profileImagePath: res.profileImagePath);
return true;
}
@@ -90,4 +99,5 @@ class UploadProfileImageNotifier extends StateNotifier<UploadProfileImageState>
}
final uploadProfileImageProvider =
StateNotifierProvider<UploadProfileImageNotifier, UploadProfileImageState>(((ref) => UploadProfileImageNotifier()));
StateNotifierProvider<UploadProfileImageNotifier, UploadProfileImageState>(
((ref) => UploadProfileImageNotifier(ref.watch(userServiceProvider))));

View File

@@ -1,21 +1,27 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_response.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
final assetServiceProvider =
Provider((ref) => AssetService(ref.watch(networkServiceProvider)));
class AssetService {
final NetworkService _networkService = NetworkService();
final NetworkService _networkService;
AssetService(this._networkService);
Future<List<ImmichAsset>?> getAllAsset() async {
var res = await _networkService.getRequest(url: "asset/");
try {
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
List<ImmichAsset> result =
List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
@@ -62,7 +68,8 @@ class AssetService {
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
List<ImmichAsset> result =
List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
if (result.isNotEmpty) {
return result;
}
@@ -90,7 +97,8 @@ class AssetService {
}
}
Future<List<DeleteAssetResponse>?> deleteAssets(Set<ImmichAsset> deleteAssets) async {
Future<List<DeleteAssetResponse>?> deleteAssets(
Set<ImmichAsset> deleteAssets) async {
try {
var payload = [];
@@ -98,11 +106,13 @@ class AssetService {
payload.add(asset.id);
}
var res = await _networkService.deleteRequest(url: "asset/", data: {"ids": payload});
var res = await _networkService
.deleteRequest(url: "asset/", data: {"ids": payload});
List<dynamic> decodedData = jsonDecode(res.toString());
List<DeleteAssetResponse> result = List.from(decodedData.map((a) => DeleteAssetResponse.fromMap(a)));
List<DeleteAssetResponse> result =
List.from(decodedData.map((a) => DeleteAssetResponse.fromMap(a)));
return result;
} catch (e) {

View File

@@ -13,7 +13,8 @@ class ControlBottomAppBar extends StatelessWidget {
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.15,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(15), topRight: Radius.circular(15)),
color: Colors.grey[300]?.withOpacity(0.98),
),
child: Column(
@@ -46,7 +47,11 @@ class ControlBottomAppBar extends StatelessWidget {
}
class ControlBoxButton extends StatelessWidget {
const ControlBoxButton({Key? key, required this.label, required this.iconData, required this.onPressed})
const ControlBoxButton(
{Key? key,
required this.label,
required this.iconData,
required this.onPressed})
: super(key: key);
final String label;

View File

@@ -18,9 +18,12 @@ class DailyTitleText extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy';
var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var formatDateTemplate =
currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy';
var dateText =
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
@@ -35,23 +38,42 @@ class DailyTitleText extends ConsumerWidget {
selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) {
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && selectedDateGroup.contains(dateText) && selectedDateGroup.length > 1) {
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length > 1) {
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).addMultipleSelectedItems(assetGroup);
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.addMultipleSelectedItems(assetGroup);
} else {
ref.watch(homePageStateProvider.notifier).enableMultiSelect(assetGroup.toSet());
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.enableMultiSelect(assetGroup.toSet());
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
}
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0),
padding: const EdgeInsets.only(
top: 29.0, bottom: 29.0, left: 12.0, right: 12.0),
child: Row(
children: [
Text(

View File

@@ -14,7 +14,8 @@ class DeleteDialog extends ConsumerWidget {
backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: const Text("Delete Permanently"),
content: const Text("These items will be permanently deleted from Immich and from your device"),
content: const Text(
"These items will be permanently deleted from Immich and from your device"),
actions: [
TextButton(
onPressed: () {
@@ -27,7 +28,9 @@ class DeleteDialog extends ConsumerWidget {
),
TextButton(
onPressed: () {
ref.watch(assetProvider.notifier).deleteAssets(homePageState.selectedItems);
ref
.watch(assetProvider.notifier)
.deleteAssets(homePageState.selectedItems);
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
Navigator.of(context).pop();

View File

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

View File

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

View File

@@ -33,7 +33,10 @@ class ImageGrid extends ConsumerWidget {
child: Row(
children: [
Text(
assetGroup[index].duration.toString().substring(0, 7),
assetGroup[index]
.duration
.toString()
.substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,

View File

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

View File

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

View File

@@ -25,7 +25,8 @@ class ThumbnailImage extends HookConsumerWidget {
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget _buildSelectionIcon(ImmichAsset asset) {
@@ -45,12 +46,20 @@ class ThumbnailImage extends HookConsumerWidget {
return GestureDetector(
onTap: () {
debugPrint("View ${asset.id}");
if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
if (isMultiSelectEnable &&
selectedAsset.contains(asset) &&
selectedAsset.length == 1) {
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
ref.watch(homePageStateProvider.notifier).removeSingleSelectedItem(asset);
} else if (isMultiSelectEnable &&
selectedAsset.contains(asset) &&
selectedAsset.length > 1) {
ref
.watch(homePageStateProvider.notifier)
.removeSingleSelectedItem(asset);
} else if (isMultiSelectEnable && !selectedAsset.contains(asset)) {
ref.watch(homePageStateProvider.notifier).addSingleSelectedItem(asset);
ref
.watch(homePageStateProvider.notifier)
.addSingleSelectedItem(asset);
} else {
if (asset.type == 'IMAGE') {
AutoRouter.of(context).push(
@@ -65,7 +74,8 @@ class ThumbnailImage extends HookConsumerWidget {
} else {
AutoRouter.of(context).push(
VideoViewerRoute(
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
videoUrl:
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
asset: asset),
);
}
@@ -83,7 +93,8 @@ class ThumbnailImage extends HookConsumerWidget {
Container(
decoration: BoxDecoration(
border: isMultiSelectEnable && selectedAsset.contains(asset)
? Border.all(color: Theme.of(context).primaryColorLight, width: 10)
? Border.all(
color: Theme.of(context).primaryColorLight, width: 10)
: const Border(),
),
child: CachedNetworkImage(
@@ -93,11 +104,15 @@ class ThumbnailImage extends HookConsumerWidget {
memCacheHeight: asset.type == 'IMAGE' ? 250 : 400,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
progressIndicatorBuilder: (context, url, downloadProgress) =>
Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
child: CircularProgressIndicator(
value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
@@ -122,7 +137,9 @@ class ThumbnailImage extends HookConsumerWidget {
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
(deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
color: Colors.white,
size: 18,
),

View File

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

View File

@@ -11,7 +11,7 @@ class AuthenticationState {
final String firstName;
final String lastName;
final bool isAdmin;
final bool isFirstLogin;
final bool shouldChangePassword;
final String profileImagePath;
final DeviceInfoRemote deviceInfo;
@@ -24,7 +24,7 @@ class AuthenticationState {
required this.firstName,
required this.lastName,
required this.isAdmin,
required this.isFirstLogin,
required this.shouldChangePassword,
required this.profileImagePath,
required this.deviceInfo,
});
@@ -38,7 +38,7 @@ class AuthenticationState {
String? firstName,
String? lastName,
bool? isAdmin,
bool? isFirstLoggedIn,
bool? shouldChangePassword,
String? profileImagePath,
DeviceInfoRemote? deviceInfo,
}) {
@@ -51,17 +51,12 @@ class AuthenticationState {
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
isAdmin: isAdmin ?? this.isAdmin,
isFirstLogin: isFirstLoggedIn ?? isFirstLogin,
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
profileImagePath: profileImagePath ?? this.profileImagePath,
deviceInfo: deviceInfo ?? this.deviceInfo,
);
}
@override
String toString() {
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, isFirstLoggedIn: $isFirstLogin, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
@@ -73,7 +68,7 @@ class AuthenticationState {
result.addAll({'firstName': firstName});
result.addAll({'lastName': lastName});
result.addAll({'isAdmin': isAdmin});
result.addAll({'isFirstLogin': isFirstLogin});
result.addAll({'shouldChangePassword': shouldChangePassword});
result.addAll({'profileImagePath': profileImagePath});
result.addAll({'deviceInfo': deviceInfo.toMap()});
@@ -90,7 +85,7 @@ class AuthenticationState {
firstName: map['firstName'] ?? '',
lastName: map['lastName'] ?? '',
isAdmin: map['isAdmin'] ?? false,
isFirstLogin: map['isFirstLogin'] ?? false,
shouldChangePassword: map['shouldChangePassword'] ?? false,
profileImagePath: map['profileImagePath'] ?? '',
deviceInfo: DeviceInfoRemote.fromMap(map['deviceInfo']),
);
@@ -98,7 +93,13 @@ class AuthenticationState {
String toJson() => json.encode(toMap());
factory AuthenticationState.fromJson(String source) => AuthenticationState.fromMap(json.decode(source));
factory AuthenticationState.fromJson(String source) =>
AuthenticationState.fromMap(json.decode(source));
@override
String toString() {
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
}
@override
bool operator ==(Object other) {
@@ -113,7 +114,7 @@ class AuthenticationState {
other.firstName == firstName &&
other.lastName == lastName &&
other.isAdmin == isAdmin &&
other.isFirstLogin == isFirstLogin &&
other.shouldChangePassword == shouldChangePassword &&
other.profileImagePath == profileImagePath &&
other.deviceInfo == deviceInfo;
}
@@ -128,7 +129,7 @@ class AuthenticationState {
firstName.hashCode ^
lastName.hashCode ^
isAdmin.hashCode ^
isFirstLogin.hashCode ^
shouldChangePassword.hashCode ^
profileImagePath.hashCode ^
deviceInfo.hashCode;
}

View File

@@ -16,5 +16,9 @@ class HiveSavedLoginInfo {
@HiveField(3)
bool isSaveLogin;
HiveSavedLoginInfo({required this.email, required this.password, required this.serverUrl, required this.isSaveLogin});
HiveSavedLoginInfo(
{required this.email,
required this.password,
required this.serverUrl,
required this.isSaveLogin});
}

View File

@@ -8,7 +8,7 @@ class LogInReponse {
final String lastName;
final String profileImagePath;
final bool isAdmin;
final bool isFirstLogin;
final bool shouldChangePassword;
LogInReponse({
required this.accessToken,
@@ -18,7 +18,7 @@ class LogInReponse {
required this.lastName,
required this.profileImagePath,
required this.isAdmin,
required this.isFirstLogin,
required this.shouldChangePassword,
});
LogInReponse copyWith({
@@ -29,7 +29,7 @@ class LogInReponse {
String? lastName,
String? profileImagePath,
bool? isAdmin,
bool? isFirstLogin,
bool? shouldChangePassword,
}) {
return LogInReponse(
accessToken: accessToken ?? this.accessToken,
@@ -39,7 +39,7 @@ class LogInReponse {
lastName: lastName ?? this.lastName,
profileImagePath: profileImagePath ?? this.profileImagePath,
isAdmin: isAdmin ?? this.isAdmin,
isFirstLogin: isFirstLogin ?? this.isFirstLogin,
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
);
}
@@ -53,7 +53,7 @@ class LogInReponse {
result.addAll({'lastName': lastName});
result.addAll({'profileImagePath': profileImagePath});
result.addAll({'isAdmin': isAdmin});
result.addAll({'isFirstLogin': isFirstLogin});
result.addAll({'shouldChangePassword': shouldChangePassword});
return result;
}
@@ -67,17 +67,18 @@ class LogInReponse {
lastName: map['lastName'] ?? '',
profileImagePath: map['profileImagePath'] ?? '',
isAdmin: map['isAdmin'] ?? false,
isFirstLogin: map['isFirstLogin'] ?? false,
shouldChangePassword: map['shouldChangePassword'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory LogInReponse.fromJson(String source) => LogInReponse.fromMap(json.decode(source));
factory LogInReponse.fromJson(String source) =>
LogInReponse.fromMap(json.decode(source));
@override
String toString() {
return 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail, firstName: $firstName, lastName: $lastName, profileImagePath: $profileImagePath, isAdmin: $isAdmin, isFirstLogin: $isFirstLogin)';
return 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail, firstName: $firstName, lastName: $lastName, profileImagePath: $profileImagePath, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword)';
}
@override
@@ -92,7 +93,7 @@ class LogInReponse {
other.lastName == lastName &&
other.profileImagePath == profileImagePath &&
other.isAdmin == isAdmin &&
other.isFirstLogin == isFirstLogin;
other.shouldChangePassword == shouldChangePassword;
}
@override
@@ -104,6 +105,6 @@ class LogInReponse {
lastName.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode ^
isFirstLogin.hashCode;
shouldChangePassword.hashCode;
}
}

View File

@@ -12,7 +12,8 @@ import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier(this.ref)
AuthenticationNotifier(
this._deviceInfoService, this._backupService, this._networkService)
: super(
AuthenticationState(
deviceId: "",
@@ -23,7 +24,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
lastName: '',
profileImagePath: '',
isAdmin: false,
isFirstLogin: false,
shouldChangePassword: false,
isAuthenticated: false,
deviceInfo: DeviceInfoRemote(
id: 0,
@@ -37,12 +38,12 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
),
);
final Ref ref;
final DeviceInfoService _deviceInfoService = DeviceInfoService();
final BackupService _backupService = BackupService();
final NetworkService _networkService = NetworkService();
final DeviceInfoService _deviceInfoService;
final BackupService _backupService;
final NetworkService _networkService;
Future<bool> login(String email, String password, String serverEndpoint, bool isSavedLoginInfo) async {
Future<bool> login(String email, String password, String serverEndpoint,
bool isSavedLoginInfo) async {
// Store server endpoint to Hive and test endpoint
if (serverEndpoint[serverEndpoint.length - 1] == "/") {
var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1);
@@ -71,7 +72,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
// Make sign-in request
try {
Response res = await _networkService.postRequest(url: 'auth/login', data: {'email': email, 'password': password});
Response res = await _networkService.postRequest(
url: 'auth/login', data: {'email': email, 'password': password});
var payload = LogInReponse.fromJson(res.toString());
@@ -85,7 +87,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
lastName: payload.lastName,
profileImagePath: payload.profileImagePath,
isAdmin: payload.isAdmin,
isFirstLoggedIn: payload.isFirstLogin,
shouldChangePassword: payload.shouldChangePassword,
);
if (isSavedLoginInfo) {
@@ -99,7 +101,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
serverUrl: Hive.box(userInfoBox).get(serverEndpointKey)),
);
} else {
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey);
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
.delete(savedLoginInfoKey);
}
} catch (e) {
return false;
@@ -107,8 +110,13 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
// Register device info
try {
Response res = await _networkService
.postRequest(url: 'device-info', data: {'deviceId': state.deviceId, 'deviceType': state.deviceType});
Response res = await _networkService.postRequest(
url: 'device-info',
data: {
'deviceId': state.deviceId,
'deviceType': state.deviceType,
},
);
DeviceInfoRemote deviceInfo = DeviceInfoRemote.fromJson(res.toString());
state = state.copyWith(deviceInfo: deviceInfo);
@@ -129,7 +137,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
firstName: '',
lastName: '',
profileImagePath: '',
isFirstLogin: false,
shouldChangePassword: false,
isAuthenticated: false,
isAdmin: false,
deviceInfo: DeviceInfoRemote(
@@ -151,15 +159,39 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
var deviceId = deviceInfo["deviceId"];
var deviceType = deviceInfo["deviceType"];
DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType);
DeviceInfoRemote deviceInfoRemote =
await _backupService.setAutoBackup(backupState, deviceId, deviceType);
state = state.copyWith(deviceInfo: deviceInfoRemote);
}
updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
Future<bool> changePassword(String newPassword) async {
Response res = await _networkService.putRequest(
url: 'user',
data: {
'id': state.userId,
'password': newPassword,
'shouldChangePassword': false,
},
);
if (res.statusCode == 200) {
state = state.copyWith(shouldChangePassword: false);
return true;
} else {
return false;
}
}
}
final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(ref);
final authenticationProvider =
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(
ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(networkServiceProvider),
);
});

View File

@@ -0,0 +1,160 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
class ChangePasswordForm extends HookConsumerWidget {
const ChangePasswordForm({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final passwordController =
useTextEditingController.fromValue(TextEditingValue.empty);
final confirmPasswordController =
useTextEditingController.fromValue(TextEditingValue.empty);
final authState = ref.watch(authenticationProvider);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: SingleChildScrollView(
child: Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.start,
children: [
Text(
'Change Password',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Text(
'Hi ${authState.firstName} ${authState.lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
),
PasswordInput(controller: passwordController),
ConfirmPasswordInput(
originalController: passwordController,
confirmController: confirmPasswordController,
),
Align(
alignment: Alignment.center,
child: ChangePasswordButton(
passwordController: passwordController),
)
],
),
),
),
);
}
}
class PasswordInput extends StatelessWidget {
final TextEditingController controller;
const PasswordInput({Key? key, required this.controller}) : super(key: key);
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: true,
controller: controller,
decoration: const InputDecoration(
labelText: 'New Password',
border: OutlineInputBorder(),
hintText: 'New Password',
),
);
}
}
class ConfirmPasswordInput extends StatelessWidget {
final TextEditingController originalController;
final TextEditingController confirmController;
const ConfirmPasswordInput({
Key? key,
required this.originalController,
required this.confirmController,
}) : super(key: key);
String? _validateInput(String? email) {
if (confirmController.value != originalController.value) {
return 'Passwords do not match';
}
return null;
}
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: true,
controller: confirmController,
decoration: const InputDecoration(
labelText: 'Confirm Password',
hintText: 'Re-enter New Password',
border: OutlineInputBorder(),
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
);
}
}
class ChangePasswordButton extends ConsumerWidget {
final TextEditingController passwordController;
const ChangePasswordButton({
Key? key,
required this.passwordController,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
visualDensity: VisualDensity.standard,
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
elevation: 2,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
),
onPressed: () async {
var isSuccess = await ref
.watch(authenticationProvider.notifier)
.changePassword(passwordController.value.text);
if (isSuccess) {
bool res =
await ref.watch(authenticationProvider.notifier).logout();
if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
}
}
},
child: const Text(
"Change Password",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
));
}
}

View File

@@ -5,6 +5,7 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
@@ -15,13 +16,17 @@ class LoginForm extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController(text: 'http://your-server-ip:2283');
final usernameController =
useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController =
useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController =
useTextEditingController(text: 'http://your-server-ip:2283/api');
final isSaveLoginInfo = useState<bool>(false);
useEffect(() {
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
var loginInfo =
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
if (loginInfo != null) {
usernameController.text = loginInfo.email;
@@ -64,11 +69,15 @@ class LoginForm extends HookConsumerWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
dense: true,
side: const BorderSide(color: Colors.grey, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
enableFeedback: true,
title: const Text(
"Save login",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey),
"Stay logged in",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey),
),
value: isSaveLoginInfo.value,
onChanged: (switchValue) {
@@ -94,11 +103,22 @@ class LoginForm extends HookConsumerWidget {
class ServerEndpointInput extends StatelessWidget {
final TextEditingController controller;
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://';
if (url == null) {
return null;
}
if (url.isEmpty) {
return 'Server endpoint is required';
}
if (!url.startsWith(RegExp(r'https?://'))) {
return 'Please specify http:// or https://';
}
return null;
}
@@ -107,7 +127,10 @@ class ServerEndpointInput extends StatelessWidget {
return TextFormField(
controller: controller,
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,
);
@@ -131,8 +154,11 @@ class EmailInput extends StatelessWidget {
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration:
const InputDecoration(labelText: 'Email', border: OutlineInputBorder(), hintText: 'youremail@email.com'),
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
hintText: 'youremail@email.com',
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
);
@@ -149,7 +175,10 @@ class PasswordInput extends StatelessWidget {
return TextFormField(
obscureText: true,
controller: controller,
decoration: const InputDecoration(labelText: 'Password', border: OutlineInputBorder(), hintText: 'password'),
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
hintText: 'password'),
);
}
}
@@ -183,17 +212,24 @@ class LoginButton extends ConsumerWidget {
ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenticated = await ref
.read(authenticationProvider.notifier)
.login(emailController.text, passwordController.text, serverEndpointController.text, isSavedLoginInfo);
.watch(authenticationProvider.notifier)
.login(emailController.text, passwordController.text,
serverEndpointController.text, isSavedLoginInfo);
if (isAuthenticated) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/tab-controller-page");
if (ref.watch(authenticationProvider).shouldChangePassword) {
AutoRouter.of(context).push(const ChangePasswordRoute());
} else {
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/tab-controller-page");
}
} else {
ImmichToast.show(
context: context,
msg: "Error logging you in, check server url, email and password!",
msg:
"Error logging you in, check server url, email and password!",
toastType: ToastType.error,
);
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/ui/change_password_form.dart';
class ChangePasswordPage extends HookConsumerWidget {
const ChangePasswordPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return const Scaffold(
body: ChangePasswordForm(),
);
}
}

View File

@@ -53,7 +53,8 @@ class CuratedLocation {
String toJson() => json.encode(toMap());
factory CuratedLocation.fromJson(String source) => CuratedLocation.fromMap(json.decode(source));
factory CuratedLocation.fromJson(String source) =>
CuratedLocation.fromMap(json.decode(source));
@override
String toString() {
@@ -74,6 +75,10 @@ class CuratedLocation {
@override
int get hashCode {
return id.hashCode ^ city.hashCode ^ resizePath.hashCode ^ deviceAssetId.hashCode ^ deviceId.hashCode;
return id.hashCode ^
city.hashCode ^
resizePath.hashCode ^
deviceAssetId.hashCode ^
deviceId.hashCode;
}
}

View File

@@ -54,7 +54,8 @@ class CuratedObject {
String toJson() => json.encode(toMap());
factory CuratedObject.fromJson(String source) => CuratedObject.fromMap(json.decode(source));
factory CuratedObject.fromJson(String source) =>
CuratedObject.fromMap(json.decode(source));
@override
String toString() {
@@ -75,6 +76,10 @@ class CuratedObject {
@override
int get hashCode {
return id.hashCode ^ object.hashCode ^ resizePath.hashCode ^ deviceAssetId.hashCode ^ deviceId.hashCode;
return id.hashCode ^
object.hashCode ^
resizePath.hashCode ^
deviceAssetId.hashCode ^
deviceId.hashCode;
}
}

View File

@@ -25,7 +25,8 @@ class SearchPageState {
searchTerm: searchTerm ?? this.searchTerm,
isSearchEnabled: isSearchEnabled ?? this.isSearchEnabled,
searchSuggestion: searchSuggestion ?? this.searchSuggestion,
userSuggestedSearchTerms: userSuggestedSearchTerms ?? this.userSuggestedSearchTerms,
userSuggestedSearchTerms:
userSuggestedSearchTerms ?? this.userSuggestedSearchTerms,
);
}
@@ -43,13 +44,15 @@ class SearchPageState {
searchTerm: map['searchTerm'] ?? '',
isSearchEnabled: map['isSearchEnabled'] ?? false,
searchSuggestion: List<String>.from(map['searchSuggestion']),
userSuggestedSearchTerms: List<String>.from(map['userSuggestedSearchTerms']),
userSuggestedSearchTerms:
List<String>.from(map['userSuggestedSearchTerms']),
);
}
String toJson() => json.encode(toMap());
factory SearchPageState.fromJson(String source) => SearchPageState.fromMap(json.decode(source));
factory SearchPageState.fromJson(String source) =>
SearchPageState.fromMap(json.decode(source));
@override
String toString() {

View File

@@ -44,13 +44,15 @@ class SearchResultPageState {
isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false,
searchResult: List<ImmichAsset>.from(map['searchResult']?.map((x) => ImmichAsset.fromMap(x))),
searchResult: List<ImmichAsset>.from(
map['searchResult']?.map((x) => ImmichAsset.fromMap(x))),
);
}
String toJson() => json.encode(toMap());
factory SearchResultPageState.fromJson(String source) => SearchResultPageState.fromMap(json.decode(source));
factory SearchResultPageState.fromJson(String source) =>
SearchResultPageState.fromMap(json.decode(source));
@override
String toString() {
@@ -71,6 +73,9 @@ class SearchResultPageState {
@override
int get hashCode {
return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode;
return isLoading.hashCode ^
isSuccess.hashCode ^
isError.hashCode ^
searchResult.hashCode;
}
}

View File

@@ -6,7 +6,7 @@ import 'package:immich_mobile/modules/search/models/search_page_state.model.dart
import 'package:immich_mobile/modules/search/services/search.service.dart';
class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
SearchPageStateNotifier()
SearchPageStateNotifier(this._searchService)
: super(
SearchPageState(
searchTerm: "",
@@ -16,7 +16,7 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
),
);
final SearchService _searchService = SearchService();
final SearchService _searchService;
void enableSearch() {
state = state.copyWith(isSearchEnabled: true);
@@ -45,20 +45,23 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
}
void getSuggestedSearchTerms() async {
var userSuggestedSearchTerms = await _searchService.getUserSuggestedSearchTerms();
var userSuggestedSearchTerms =
await _searchService.getUserSuggestedSearchTerms();
state = state.copyWith(userSuggestedSearchTerms: userSuggestedSearchTerms);
}
}
final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
return SearchPageStateNotifier();
final searchPageStateProvider =
StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
return SearchPageStateNotifier(ref.watch(searchServiceProvider));
});
final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocation>>((ref) async {
final SearchService _searchService = SearchService();
final getCuratedLocationProvider =
FutureProvider.autoDispose<List<CuratedLocation>>((ref) async {
final SearchService searchService = ref.watch(searchServiceProvider);
var curatedLocation = await _searchService.getCuratedLocation();
var curatedLocation = await searchService.getCuratedLocation();
if (curatedLocation != null) {
return curatedLocation;
} else {
@@ -66,10 +69,11 @@ final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocati
}
});
final getCuratedObjectProvider = FutureProvider.autoDispose<List<CuratedObject>>((ref) async {
final SearchService _searchService = SearchService();
final getCuratedObjectProvider =
FutureProvider.autoDispose<List<CuratedObject>>((ref) async {
final SearchService searchService = ref.watch(searchServiceProvider);
var curatedObject = await _searchService.getCuratedObjects();
var curatedObject = await searchService.getCuratedObjects();
if (curatedObject != null) {
return curatedObject;
} else {

View File

@@ -7,31 +7,48 @@ import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:intl/intl.dart';
class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
SearchResultPageNotifier()
: super(SearchResultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false));
SearchResultPageNotifier(this._searchService)
: super(
SearchResultPageState(
searchResult: [],
isError: false,
isLoading: true,
isSuccess: false,
),
);
final SearchService _searchService = SearchService();
final SearchService _searchService;
void search(String searchTerm) async {
state = state.copyWith(searchResult: [], isError: false, isLoading: true, isSuccess: false);
state = state.copyWith(
searchResult: [], isError: false, isLoading: true, isSuccess: false);
List<ImmichAsset>? assets = await _searchService.searchAsset(searchTerm);
if (assets != null) {
state = state.copyWith(searchResult: assets, isError: false, isLoading: false, isSuccess: true);
state = state.copyWith(
searchResult: assets,
isError: false,
isLoading: false,
isSuccess: true);
} else {
state = state.copyWith(searchResult: [], isError: true, isLoading: false, isSuccess: false);
state = state.copyWith(
searchResult: [], isError: true, isLoading: false, isSuccess: false);
}
}
}
final searchResultPageProvider = StateNotifierProvider<SearchResultPageNotifier, SearchResultPageState>((ref) {
return SearchResultPageNotifier();
final searchResultPageProvider =
StateNotifierProvider<SearchResultPageNotifier, SearchResultPageState>(
(ref) {
return SearchResultPageNotifier(ref.watch(searchServiceProvider));
});
final searchResultGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(searchResultPageProvider).searchResult;
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
return assets.groupListsBy((element) =>
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
});

View File

@@ -1,13 +1,18 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
final searchServiceProvider =
Provider((ref) => SearchService(ref.watch(networkServiceProvider)));
class SearchService {
final NetworkService _networkService = NetworkService();
final NetworkService _networkService;
SearchService(this._networkService);
Future<List<String>?> getUserSuggestedSearchTerms() async {
try {
@@ -30,7 +35,8 @@ class SearchService {
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
List<ImmichAsset> result =
List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
return result;
} catch (e) {
@@ -45,7 +51,8 @@ class SearchService {
List<dynamic> decodedData = jsonDecode(res.toString());
List<CuratedLocation> result = List.from(decodedData.map((a) => CuratedLocation.fromMap(a)));
List<CuratedLocation> result =
List.from(decodedData.map((a) => CuratedLocation.fromMap(a)));
return result;
} catch (e) {
@@ -60,7 +67,8 @@ class SearchService {
List<dynamic> decodedData = jsonDecode(res.toString());
List<CuratedObject> result = List.from(decodedData.map((a) => CuratedObject.fromMap(a)));
List<CuratedObject> result =
List.from(decodedData.map((a) => CuratedObject.fromMap(a)));
return result;
} catch (e) {

View File

@@ -4,7 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
SearchBar({Key? key, required this.searchFocusNode, required this.onSubmitted}) : super(key: key);
SearchBar(
{Key? key, required this.searchFocusNode, required this.onSubmitted})
: super(key: key);
final FocusNode searchFocusNode;
final Function(String) onSubmitted;

View File

@@ -3,16 +3,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class SearchSuggestionList extends ConsumerWidget {
const SearchSuggestionList({Key? key, required this.onSubmitted}) : super(key: key);
const SearchSuggestionList({Key? key, required this.onSubmitted})
: super(key: key);
final Function(String) onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
final searchSuggestion = ref.watch(searchPageStateProvider).searchSuggestion;
final searchSuggestion =
ref.watch(searchPageStateProvider).searchSuggestion;
return Container(
color: searchTerm.isEmpty ? Colors.black.withOpacity(0.5) : Theme.of(context).scaffoldBackgroundColor,
color: searchTerm.isEmpty
? Colors.black.withOpacity(0.5)
: Theme.of(context).scaffoldBackgroundColor,
child: CustomScrollView(
slivers: [
SliverFillRemaining(

View File

@@ -5,7 +5,11 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
class ThumbnailWithInfo extends StatelessWidget {
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
const ThumbnailWithInfo(
{Key? key,
required this.textInfo,
required this.imageUrl,
required this.onTap})
: super(key: key);
final String textInfo;
@@ -39,7 +43,9 @@ class ThumbnailWithInfo extends StatelessWidget {
height: 250,
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
),
),
),

View File

@@ -1,7 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -19,7 +18,7 @@ import 'package:immich_mobile/utils/capitalize_first_letter.dart';
class SearchPage extends HookConsumerWidget {
SearchPage({Key? key}) : super(key: key);
late FocusNode searchFocusNode;
FocusNode searchFocusNode = FocusNode();
@override
Widget build(BuildContext context, WidgetRef ref) {

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';
class SearchResultPage extends HookConsumerWidget {
SearchResultPage({Key? key, required this.searchTerm}) : super(key: key);
const SearchResultPage({Key? key, required this.searchTerm})
: super(key: key);
final String searchTerm;
late FocusNode searchFocusNode;
@override
Widget build(BuildContext context, WidgetRef ref) {
ScrollController _scrollController = useScrollController();
ScrollController scrollController = useScrollController();
final searchTermController = useTextEditingController(text: "");
final isNewSearch = useState(false);
final currentSearchTerm = useState(searchTerm);
List<Widget> _imageGridGroup = [];
final List<Widget> imageGridGroup = [];
late FocusNode searchFocusNode;
useEffect(() {
searchFocusNode = FocusNode();
Future.delayed(Duration.zero, () => ref.read(searchResultPageProvider.notifier).search(searchTerm));
Future.delayed(Duration.zero,
() => ref.read(searchResultPageProvider.notifier).search(searchTerm));
return () => searchFocusNode.dispose();
}, []);
@@ -85,7 +88,10 @@ class SearchResultPage extends HookConsumerWidget {
children: [
Text(
currentSearchTerm.value,
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 13, fontWeight: FontWeight.bold),
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 13,
fontWeight: FontWeight.bold),
maxLines: 1,
),
Icon(
@@ -124,7 +130,7 @@ class SearchResultPage extends HookConsumerWidget {
if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) {
_imageGridGroup.add(
imageGridGroup.add(
MonthlyTitleText(
isoDate: dateGroup,
),
@@ -132,14 +138,14 @@ class SearchResultPage extends HookConsumerWidget {
}
}
_imageGridGroup.add(
imageGridGroup.add(
DailyTitleText(
isoDate: dateGroup,
assetGroup: immichAssetList,
),
);
_imageGridGroup.add(
imageGridGroup.add(
ImageGrid(assetGroup: immichAssetList),
);
@@ -148,11 +154,11 @@ class SearchResultPage extends HookConsumerWidget {
return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: _scrollController,
slivers: [..._imageGridGroup],
controller: scrollController,
slivers: [...imageGridGroup],
),
);
} else {
@@ -192,7 +198,9 @@ class SearchResultPage extends HookConsumerWidget {
child: Stack(
children: [
_buildSearchResult(),
isNewSearch.value ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
isNewSearch.value
? SearchSuggestionList(onSubmitted: _onSearchSubmitted)
: Container(),
],
),
),

View File

@@ -36,16 +36,20 @@ class AlbumViewerPageState {
String toJson() => json.encode(toMap());
factory AlbumViewerPageState.fromJson(String source) => AlbumViewerPageState.fromMap(json.decode(source));
factory AlbumViewerPageState.fromJson(String source) =>
AlbumViewerPageState.fromMap(json.decode(source));
@override
String toString() => 'AlbumViewerPageState(isEditAlbum: $isEditAlbum, editTitleText: $editTitleText)';
String toString() =>
'AlbumViewerPageState(isEditAlbum: $isEditAlbum, editTitleText: $editTitleText)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AlbumViewerPageState && other.isEditAlbum == isEditAlbum && other.editTitleText == editTitleText;
return other is AlbumViewerPageState &&
other.isEditAlbum == isEditAlbum &&
other.editTitleText == editTitleText;
}
@override

View File

@@ -22,7 +22,8 @@ class AssetSelectionPageResult {
}) {
return AssetSelectionPageResult(
selectedNewAsset: selectedNewAsset ?? this.selectedNewAsset,
selectedAdditionalAsset: selectedAdditionalAsset ?? this.selectedAdditionalAsset,
selectedAdditionalAsset:
selectedAdditionalAsset ?? this.selectedAdditionalAsset,
isAlbumExist: isAlbumExist ?? this.isAlbumExist,
);
}
@@ -30,8 +31,12 @@ class AssetSelectionPageResult {
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'selectedNewAsset': selectedNewAsset.map((x) => x.toMap()).toList()});
result.addAll({'selectedAdditionalAsset': selectedAdditionalAsset.map((x) => x.toMap()).toList()});
result.addAll(
{'selectedNewAsset': selectedNewAsset.map((x) => x.toMap()).toList()});
result.addAll({
'selectedAdditionalAsset':
selectedAdditionalAsset.map((x) => x.toMap()).toList()
});
result.addAll({'isAlbumExist': isAlbumExist});
return result;
@@ -39,16 +44,18 @@ class AssetSelectionPageResult {
factory AssetSelectionPageResult.fromMap(Map<String, dynamic> map) {
return AssetSelectionPageResult(
selectedNewAsset: Set<ImmichAsset>.from(map['selectedNewAsset']?.map((x) => ImmichAsset.fromMap(x))),
selectedAdditionalAsset:
Set<ImmichAsset>.from(map['selectedAdditionalAsset']?.map((x) => ImmichAsset.fromMap(x))),
selectedNewAsset: Set<ImmichAsset>.from(
map['selectedNewAsset']?.map((x) => ImmichAsset.fromMap(x))),
selectedAdditionalAsset: Set<ImmichAsset>.from(
map['selectedAdditionalAsset']?.map((x) => ImmichAsset.fromMap(x))),
isAlbumExist: map['isAlbumExist'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory AssetSelectionPageResult.fromJson(String source) => AssetSelectionPageResult.fromMap(json.decode(source));
factory AssetSelectionPageResult.fromJson(String source) =>
AssetSelectionPageResult.fromMap(json.decode(source));
@override
String toString() =>
@@ -66,5 +73,8 @@ class AssetSelectionPageResult {
}
@override
int get hashCode => selectedNewAsset.hashCode ^ selectedAdditionalAsset.hashCode ^ isAlbumExist.hashCode;
int get hashCode =>
selectedNewAsset.hashCode ^
selectedAdditionalAsset.hashCode ^
isAlbumExist.hashCode;
}

View File

@@ -32,9 +32,12 @@ class AssetSelectionState {
}) {
return AssetSelectionState(
selectedMonths: selectedMonths ?? this.selectedMonths,
selectedNewAssetsForAlbum: selectedNewAssetsForAlbum ?? this.selectedNewAssetsForAlbum,
selectedAdditionalAssetsForAlbum: selectedAdditionalAssetsForAlbum ?? this.selectedAdditionalAssetsForAlbum,
selectedAssetsInAlbumViewer: selectedAssetsInAlbumViewer ?? this.selectedAssetsInAlbumViewer,
selectedNewAssetsForAlbum:
selectedNewAssetsForAlbum ?? this.selectedNewAssetsForAlbum,
selectedAdditionalAssetsForAlbum: selectedAdditionalAssetsForAlbum ??
this.selectedAdditionalAssetsForAlbum,
selectedAssetsInAlbumViewer:
selectedAssetsInAlbumViewer ?? this.selectedAssetsInAlbumViewer,
isMultiselectEnable: isMultiselectEnable ?? this.isMultiselectEnable,
isAlbumExist: isAlbumExist ?? this.isAlbumExist,
);
@@ -44,10 +47,18 @@ class AssetSelectionState {
final result = <String, dynamic>{};
result.addAll({'selectedMonths': selectedMonths.toList()});
result.addAll({'selectedNewAssetsForAlbum': selectedNewAssetsForAlbum.map((x) => x.toMap()).toList()});
result
.addAll({'selectedAdditionalAssetsForAlbum': selectedAdditionalAssetsForAlbum.map((x) => x.toMap()).toList()});
result.addAll({'selectedAssetsInAlbumViewer': selectedAssetsInAlbumViewer.map((x) => x.toMap()).toList()});
result.addAll({
'selectedNewAssetsForAlbum':
selectedNewAssetsForAlbum.map((x) => x.toMap()).toList()
});
result.addAll({
'selectedAdditionalAssetsForAlbum':
selectedAdditionalAssetsForAlbum.map((x) => x.toMap()).toList()
});
result.addAll({
'selectedAssetsInAlbumViewer':
selectedAssetsInAlbumViewer.map((x) => x.toMap()).toList()
});
result.addAll({'isMultiselectEnable': isMultiselectEnable});
result.addAll({'isAlbumExist': isAlbumExist});
@@ -57,12 +68,14 @@ class AssetSelectionState {
factory AssetSelectionState.fromMap(Map<String, dynamic> map) {
return AssetSelectionState(
selectedMonths: Set<String>.from(map['selectedMonths']),
selectedNewAssetsForAlbum:
Set<ImmichAsset>.from(map['selectedNewAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))),
selectedAdditionalAssetsForAlbum:
Set<ImmichAsset>.from(map['selectedAdditionalAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))),
selectedAssetsInAlbumViewer:
Set<ImmichAsset>.from(map['selectedAssetsInAlbumViewer']?.map((x) => ImmichAsset.fromMap(x))),
selectedNewAssetsForAlbum: Set<ImmichAsset>.from(
map['selectedNewAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))),
selectedAdditionalAssetsForAlbum: Set<ImmichAsset>.from(
map['selectedAdditionalAssetsForAlbum']
?.map((x) => ImmichAsset.fromMap(x))),
selectedAssetsInAlbumViewer: Set<ImmichAsset>.from(
map['selectedAssetsInAlbumViewer']
?.map((x) => ImmichAsset.fromMap(x))),
isMultiselectEnable: map['isMultiselectEnable'] ?? false,
isAlbumExist: map['isAlbumExist'] ?? false,
);
@@ -70,7 +83,8 @@ class AssetSelectionState {
String toJson() => json.encode(toMap());
factory AssetSelectionState.fromJson(String source) => AssetSelectionState.fromMap(json.decode(source));
factory AssetSelectionState.fromJson(String source) =>
AssetSelectionState.fromMap(json.decode(source));
@override
String toString() {
@@ -85,8 +99,10 @@ class AssetSelectionState {
return other is AssetSelectionState &&
setEquals(other.selectedMonths, selectedMonths) &&
setEquals(other.selectedNewAssetsForAlbum, selectedNewAssetsForAlbum) &&
setEquals(other.selectedAdditionalAssetsForAlbum, selectedAdditionalAssetsForAlbum) &&
setEquals(other.selectedAssetsInAlbumViewer, selectedAssetsInAlbumViewer) &&
setEquals(other.selectedAdditionalAssetsForAlbum,
selectedAdditionalAssetsForAlbum) &&
setEquals(
other.selectedAssetsInAlbumViewer, selectedAssetsInAlbumViewer) &&
other.isMultiselectEnable == isMultiselectEnable &&
other.isAlbumExist == isAlbumExist;
}

View File

@@ -38,7 +38,8 @@ class SharedAlbum {
ownerId: ownerId ?? this.ownerId,
albumName: albumName ?? this.albumName,
createdAt: createdAt ?? this.createdAt,
albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId,
albumThumbnailAssetId:
albumThumbnailAssetId ?? this.albumThumbnailAssetId,
sharedUsers: sharedUsers ?? this.sharedUsers,
assets: assets ?? this.assets,
);
@@ -69,16 +70,19 @@ class SharedAlbum {
albumName: map['albumName'] ?? '',
createdAt: map['createdAt'] ?? '',
albumThumbnailAssetId: map['albumThumbnailAssetId'],
sharedUsers: List<User>.from(map['sharedUsers']?.map((x) => User.fromMap(x))),
sharedUsers:
List<User>.from(map['sharedUsers']?.map((x) => User.fromMap(x))),
assets: map['assets'] != null
? List<ImmichAsset>.from(map['assets']?.map((x) => ImmichAsset.fromMap(x)))
? List<ImmichAsset>.from(
map['assets']?.map((x) => ImmichAsset.fromMap(x)))
: null,
);
}
String toJson() => json.encode(toMap());
factory SharedAlbum.fromJson(String source) => SharedAlbum.fromMap(json.decode(source));
factory SharedAlbum.fromJson(String source) =>
SharedAlbum.fromMap(json.decode(source));
@override
String toString() {

View File

@@ -12,4 +12,5 @@ class AlbumTitleNotifier extends StateNotifier<String> {
}
}
final albumTitleProvider = StateNotifierProvider<AlbumTitleNotifier, String>((ref) => AlbumTitleNotifier());
final albumTitleProvider = StateNotifierProvider<AlbumTitleNotifier, String>(
(ref) => AlbumTitleNotifier());

View File

@@ -4,7 +4,8 @@ import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.da
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
AlbumViewerNotifier(this.ref) : super(AlbumViewerPageState(editTitleText: "", isEditAlbum: false));
AlbumViewerNotifier(this.ref)
: super(AlbumViewerPageState(editTitleText: "", isEditAlbum: false));
final Ref ref;
@@ -28,10 +29,12 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
state = state.copyWith(editTitleText: "", isEditAlbum: false);
}
Future<bool> changeAlbumTitle(String albumId, String ownerId, String newAlbumTitle) async {
SharedAlbumService service = SharedAlbumService();
Future<bool> changeAlbumTitle(
String albumId, String ownerId, String newAlbumTitle) async {
SharedAlbumService service = ref.watch(sharedAlbumServiceProvider);
bool isSuccess = await service.changeTitleAlbum(albumId, ownerId, newAlbumTitle);
bool isSuccess =
await service.changeTitleAlbum(albumId, ownerId, newAlbumTitle);
if (isSuccess) {
state = state.copyWith(editTitleText: "", isEditAlbum: false);
@@ -45,6 +48,7 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
}
}
final albumViewerProvider = StateNotifierProvider<AlbumViewerNotifier, AlbumViewerPageState>((ref) {
final albumViewerProvider =
StateNotifierProvider<AlbumViewerNotifier, AlbumViewerPageState>((ref) {
return AlbumViewerNotifier(ref);
});

View File

@@ -18,35 +18,48 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
state = state.copyWith(isAlbumExist: isAlbumExist);
}
void removeAssetsInMonth(String removedMonth, List<ImmichAsset> assetsInMonth) {
void removeAssetsInMonth(
String removedMonth, List<ImmichAsset> assetsInMonth) {
Set<ImmichAsset> currentAssetList = state.selectedNewAssetsForAlbum;
Set<String> currentMonthList = state.selectedMonths;
currentMonthList.removeWhere((selectedMonth) => selectedMonth == removedMonth);
currentMonthList
.removeWhere((selectedMonth) => selectedMonth == removedMonth);
for (ImmichAsset asset in assetsInMonth) {
currentAssetList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedNewAssetsForAlbum: currentAssetList, selectedMonths: currentMonthList);
state = state.copyWith(
selectedNewAssetsForAlbum: currentAssetList,
selectedMonths: currentMonthList);
}
void addAdditionalAssets(List<ImmichAsset> assets) {
state = state.copyWith(
selectedAdditionalAssetsForAlbum: {...state.selectedAdditionalAssetsForAlbum, ...assets},
selectedAdditionalAssetsForAlbum: {
...state.selectedAdditionalAssetsForAlbum,
...assets
},
);
}
void addAllAssetsInMonth(String month, List<ImmichAsset> assetsInMonth) {
state = state.copyWith(
selectedMonths: {...state.selectedMonths, month},
selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assetsInMonth},
selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum,
...assetsInMonth
},
);
}
void addNewAssets(List<ImmichAsset> assets) {
state = state.copyWith(
selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assets},
selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum,
...assets
},
);
}
@@ -93,7 +106,10 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
void addAssetsInAlbumViewer(List<ImmichAsset> assets) {
state = state.copyWith(
selectedAssetsInAlbumViewer: {...state.selectedAssetsInAlbumViewer, ...assets},
selectedAssetsInAlbumViewer: {
...state.selectedAssetsInAlbumViewer,
...assets
},
);
}
@@ -108,6 +124,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
}
}
final assetSelectionProvider = StateNotifierProvider<AssetSelectionNotifier, AssetSelectionState>((ref) {
final assetSelectionProvider =
StateNotifierProvider<AssetSelectionNotifier, AssetSelectionState>((ref) {
return AssetSelectionNotifier();
});

View File

@@ -3,12 +3,13 @@ import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
SharedAlbumNotifier() : super([]);
SharedAlbumNotifier(this._sharedAlbumService) : super([]);
final SharedAlbumService _sharedAlbumService = SharedAlbumService();
final SharedAlbumService _sharedAlbumService;
getAllSharedAlbums() async {
List<SharedAlbum> sharedAlbums = await _sharedAlbumService.getAllSharedAlbum();
List<SharedAlbum> sharedAlbums =
await _sharedAlbumService.getAllSharedAlbum();
state = sharedAlbums;
}
@@ -35,7 +36,8 @@ class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
}
}
Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async {
Future<bool> removeAssetFromAlbum(
String albumId, List<String> assetIds) async {
var res = await _sharedAlbumService.removeAssetFromAlbum(albumId, assetIds);
if (res) {
@@ -46,12 +48,15 @@ class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
}
}
final sharedAlbumProvider = StateNotifierProvider<SharedAlbumNotifier, List<SharedAlbum>>((ref) {
return SharedAlbumNotifier();
final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<SharedAlbum>>((ref) {
return SharedAlbumNotifier(ref.watch(sharedAlbumServiceProvider));
});
final sharedAlbumDetailProvider = FutureProvider.autoDispose.family<SharedAlbum, String>((ref, albumId) async {
final SharedAlbumService _sharedAlbumService = SharedAlbumService();
final sharedAlbumDetailProvider = FutureProvider.autoDispose
.family<SharedAlbum, String>((ref, albumId) async {
final SharedAlbumService sharedAlbumService =
ref.watch(sharedAlbumServiceProvider);
return await _sharedAlbumService.getAlbumDetail(albumId);
return await sharedAlbumService.getAlbumDetail(albumId);
});

View File

@@ -2,8 +2,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/user.model.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
final suggestedSharedUsersProvider = FutureProvider.autoDispose<List<User>>((ref) async {
UserService userService = UserService();
final suggestedSharedUsersProvider =
FutureProvider.autoDispose<List<User>>((ref) async {
UserService userService = ref.watch(userServiceProvider);
return await userService.getAllUsersInfo();
});

View File

@@ -3,12 +3,17 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
final sharedAlbumServiceProvider =
Provider((ref) => SharedAlbumService(ref.watch(networkServiceProvider)));
class SharedAlbumService {
final NetworkService _networkService = NetworkService();
final NetworkService _networkService;
SharedAlbumService(this._networkService);
Future<List<SharedAlbum>> getAllSharedAlbum() async {
try {

View File

@@ -5,7 +5,11 @@ class AlbumActionOutlinedButton extends StatelessWidget {
final String labelText;
final IconData iconData;
const AlbumActionOutlinedButton({Key? key, this.onPressed, required this.labelText, required this.iconData})
const AlbumActionOutlinedButton(
{Key? key,
this.onPressed,
required this.labelText,
required this.iconData})
: super(key: key);
@override
@@ -26,7 +30,8 @@ class AlbumActionOutlinedButton extends StatelessWidget {
icon: Icon(iconData, size: 15),
label: Text(
labelText,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87),
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87),
),
onPressed: onPressed,
),

View File

@@ -29,7 +29,8 @@ class AlbumTitleTextField extends ConsumerWidget {
ref.watch(albumTitleProvider.notifier).setAlbumTitle(v);
},
focusNode: albumTitleTextFieldFocusNode,
style: TextStyle(fontSize: 28, color: Colors.grey[700], fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 28, color: Colors.grey[700], fontWeight: FontWeight.bold),
controller: albumTitleController,
onTap: () {
isAlbumTitleTextFieldFocus.value = true;

View File

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

View File

@@ -7,11 +7,14 @@ import 'package:immich_mobile/modules/sharing/providers/album_viewer.provider.da
class AlbumViewerEditableTitle extends HookConsumerWidget {
final SharedAlbum albumInfo;
final FocusNode titleFocusNode;
const AlbumViewerEditableTitle({Key? key, required this.albumInfo, required this.titleFocusNode}) : super(key: key);
const AlbumViewerEditableTitle(
{Key? key, required this.albumInfo, required this.titleFocusNode})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final titleTextEditController = useTextEditingController(text: albumInfo.albumName);
final titleTextEditController =
useTextEditingController(text: albumInfo.albumName);
void onFocusModeChange() {
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
@@ -40,7 +43,9 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
onTap: () {
FocusScope.of(context).requestFocus(titleFocusNode);
ref.watch(albumViewerProvider.notifier).setEditTitleText(albumInfo.albumName);
ref
.watch(albumViewerProvider.notifier)
.setEditTitleText(albumInfo.albumName);
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
if (titleTextEditController.text == 'Untitled') {

View File

@@ -22,8 +22,10 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
var deviceId = ref.watch(authenticationProvider).deviceId;
final selectedAssetsInAlbumViewer = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
final selectedAssetsInAlbumViewer =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final isMultiSelectionEnable =
ref.watch(assetSelectionProvider).isMultiselectEnable;
_viewAsset() {
if (asset.type == 'IMAGE') {
@@ -39,7 +41,8 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
} else {
AutoRouter.of(context).push(
VideoViewerRoute(
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
videoUrl:
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
asset: asset),
);
}
@@ -58,7 +61,9 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
_enableMultiSelection() {
ref.watch(assetSelectionProvider.notifier).enableMultiselection();
ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]);
ref
.watch(assetSelectionProvider.notifier)
.addAssetsInAlbumViewer([asset]);
}
_disableMultiSelection() {
@@ -96,7 +101,9 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
(deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
color: Colors.white,
size: 18,
),
@@ -136,7 +143,8 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
progressIndicatorBuilder: (context, url, downloadProgress) =>
Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
@@ -152,13 +160,17 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
_handleSelectionGesture() {
if (selectedAssetsInAlbumViewer.contains(asset)) {
ref.watch(assetSelectionProvider.notifier).removeAssetsInAlbumViewer([asset]);
ref
.watch(assetSelectionProvider.notifier)
.removeAssetsInAlbumViewer([asset]);
if (selectedAssetsInAlbumViewer.isEmpty) {
_disableMultiSelection();
}
} else {
ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]);
ref
.watch(assetSelectionProvider.notifier)
.addAssetsInAlbumViewer([asset]);
}
}

View File

@@ -5,7 +5,8 @@ import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class AssetGridByMonth extends HookConsumerWidget {
final List<ImmichAsset> assetGroup;
const AssetGridByMonth({Key? key, required this.assetGroup}) : super(key: key);
const AssetGridByMonth({Key? key, required this.assetGroup})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverGrid(

View File

@@ -8,12 +8,15 @@ class MonthGroupTitle extends HookConsumerWidget {
final String month;
final List<ImmichAsset> assetGroup;
const MonthGroupTitle({Key? key, required this.month, required this.assetGroup}) : super(key: key);
const MonthGroupTitle(
{Key? key, required this.month, required this.assetGroup})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedDateGroup = ref.watch(assetSelectionProvider).selectedMonths;
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final selectedAssets =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
_handleTitleIconClick() {
@@ -21,10 +24,16 @@ class MonthGroupTitle extends HookConsumerWidget {
if (isAlbumExist) {
if (selectedDateGroup.contains(month)) {
ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, []);
ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets(assetGroup);
ref
.watch(assetSelectionProvider.notifier)
.removeAssetsInMonth(month, []);
ref
.watch(assetSelectionProvider.notifier)
.removeSelectedAdditionalAssets(assetGroup);
} else {
ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, []);
ref
.watch(assetSelectionProvider.notifier)
.addAllAssetsInMonth(month, []);
// Deep clone assetGroup
var assetGroupWithNewItems = [...assetGroup];
@@ -33,13 +42,19 @@ class MonthGroupTitle extends HookConsumerWidget {
assetGroupWithNewItems.removeWhere((a) => a.id == selectedAsset.id);
}
ref.watch(assetSelectionProvider.notifier).addAdditionalAssets(assetGroupWithNewItems);
ref
.watch(assetSelectionProvider.notifier)
.addAdditionalAssets(assetGroupWithNewItems);
}
} else {
if (selectedDateGroup.contains(month)) {
ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, assetGroup);
ref
.watch(assetSelectionProvider.notifier)
.removeAssetsInMonth(month, assetGroup);
} else {
ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, assetGroup);
ref
.watch(assetSelectionProvider.notifier)
.addAllAssetsInMonth(month, assetGroup);
}
}
}
@@ -59,7 +74,8 @@ class MonthGroupTitle extends HookConsumerWidget {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 14.0, right: 8.0),
padding: const EdgeInsets.only(
top: 29.0, bottom: 29.0, left: 14.0, right: 8.0),
child: Row(
children: [
GestureDetector(

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