Compare commits

...

39 Commits

Author SHA1 Message Date
Alex Tran
b34de624ce Added changelog for F-droid 2022-05-29 09:11:22 -05:00
Alex Tran
7886c42742 Update Fastlane iOS build version + speicify database container to restart always 2022-05-29 08:42:27 -05:00
Alex
d476b15312 Implemented user profile upload and show on web/mobile (#191)
* Update mobile dependencies

* Added image picker

* Added mechanism to upload profile image

* Added image type to send to web

* Added styling for circle avatar

* Fixxed issue with sharp cannot resize image properly

* Finished displaying and uploading user profile

* Added user profile to web
2022-05-28 22:35:45 -05:00
Alex Tran
bdf38e7668 Added endpoint for getting user profile image 2022-05-27 22:24:58 -05:00
Alex Tran
e33566a04a Upload profile picture and convert into webp 2022-05-27 22:15:35 -05:00
Alex
c28251b8b4 [WEB] View large images on web (#189)
* Added selection icon to thumbnail

* Added micro-interaction and video file indication

* Added page to add page

* Added image viewer

* navigate assets

* Added separate component for viewing the video file

* Added FFmpeg modules

* Added correct content-type header for serving image file

* Added loading spinner
2022-05-27 14:02:06 -05:00
Alex Tran
337db1c508 fixed typo for bug template 2022-05-26 15:12:08 -05:00
Alex Tran
ad2a1ba901 Update and add issue templates 2022-05-26 15:10:50 -05:00
Alex Tran
fa6f6f8e9f Fixed default DB_HOSTNAME to be the correct database container 2022-05-24 08:49:38 -05:00
Pavle Portic
a44043a4e5 Add ability to pass redis hostname as env var (#174)
* Add ability to pass redis hostname as env var

* Read postgres host from env var in microservices

* Update .env.example with postgres and redis hostname vars
2022-05-23 17:23:02 -05:00
shruuub
87b15c60c0 Fix typos and grammar, plus change Linux to Unix (#180)
Fixed Readme typo
2022-05-23 11:58:00 -05:00
Alex
2c83e52c15 Added dependency in docker-compose (#173)
* Added dependency in docker-compose
* downgrade sharp version to 0.28
2022-05-22 10:15:38 -05:00
Alex
55c5027539 Add webp thumbnail conversion task to optimize performance of fast scrolling (#172)
* Update readme

* Added webp to table and entity

* Added cronjob and sharp dependencies

* Added conversion of webp every 5 minutes and endpoint will now server webp image if exist
2022-05-22 06:56:36 -05:00
Alex
ce06af0c9b Fixed lodash library not invoking in production build (#171)
* Added staging docker-compose file
* Use lodash-es and remove hydration option on photos page fixed the problem
2022-05-22 04:48:38 -05:00
Alex Tran
baaf7ad153 disable hydration 2022-05-21 23:50:54 -05:00
Alex Tran
4a25e7dc22 remove footer to fix error when reloading the page 2022-05-21 23:36:58 -05:00
Alex Tran
f90563d18c not prerender photos page 2022-05-21 23:28:02 -05:00
Alex Tran
8352ecd3b9 Update readme 2022-05-21 18:53:30 -05:00
Alex Tran
69b34a4364 Update readme 2022-05-21 18:49:23 -05:00
Alex
6023c3c624 Show assets on web (#168)
* Implemented lazy loading thumbnail
* Display assets as date-time grouping
* Update Readme
* Modify GitHub action to run from the latest update
2022-05-21 16:50:56 -05:00
Alex Tran
171e7ffa77 Update readme 2022-05-21 08:30:27 -05:00
Alex Tran
d9f918005a Added python3 to prod target of web Dockerfile 2022-05-21 02:34:39 -05:00
Alex Tran
e8ade4866b Added python3 to docker image of web 2022-05-21 02:30:00 -05:00
Alex Tran
bbfa789a4e update readme 2022-05-21 02:25:15 -05:00
Alex
a779c3803c Add web interface with admin functionality (#167) 2022-05-21 02:23:55 -05:00
Jaime Baez
79dea504b0 Add e2e testing setup (#163)
* Setup e2e testing

* Add user e2e tests

* Rename database host env variable to DB_HOST

* Force push (try to recover DB_HOST env)

* Rename db host env variable to `DB_HOSTNAME`

* Remove unnecessary `initDb` from test-utils

The current database.config is running the migrations:
`migrationsRun: true`
2022-05-19 18:30:47 -05:00
Migelo
4900fecd10 fix immich-server service name in README (#166) 2022-05-18 06:26:37 -04:00
Alex
adfaab7eb2 Update to flutter 3 (#162) 2022-05-14 09:25:19 -05:00
Alex
c5adbea6e1 Fixed incorrect microservices URLs after updating dockerfiles (#159) 2022-05-11 06:18:11 -05:00
Alex
bb89fa4aab Modify docker-compose to be compatible with k8s (#149)
* Modify docker-compose file using a hyphen for services instead of underscore

* Change URL in Nginx setting
2022-05-08 07:07:58 -05:00
Alex
43d639104d Bug/fixed permission not requested android 10 (#150)
* Added  android:requestLegacyExternalStorage=true to manifest

* Up pubspec version code for android build
2022-05-08 06:47:38 -05:00
Alex
a1792a7d94 Pump docker-compose container version 2022-05-06 07:47:19 -05:00
Alex
373b6918f8 Feature #120 #89 selective backup in app (#137) 2022-05-06 07:22:23 -05:00
dependabot[bot]
f1396761b0 Bump docker/setup-buildx-action from 1.6.0 to 2.0.0 (#141)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1.6.0 to 2.0.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1.6.0...v2.0.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-06 07:21:33 -05:00
dependabot[bot]
335bb0707c Bump docker/build-push-action from 2.10.0 to 3.0.0 (#142)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 2.10.0 to 3.0.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v2.10.0...v3.0.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-06 07:21:21 -05:00
dependabot[bot]
7a51e0dd4d Bump docker/login-action from 1 to 2 (#143)
Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-06 07:21:11 -05:00
dependabot[bot]
5b42899dde Bump docker/setup-qemu-action from 1.2.0 to 2.0.0 (#140)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 1.2.0 to 2.0.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v1.2.0...v2.0.0)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-06 07:20:50 -05:00
Alex
229357df2b Appbar on homepage is fixed so the cursor won't be overlapping when scrolling 2022-04-30 17:03:45 -05:00
Alex
8d5626620b Refactor homepage widget 2022-04-30 10:55:27 -05:00
169 changed files with 11603 additions and 811 deletions

View File

@@ -1,15 +1,26 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
title: '[BUG] <title>'
labels: bug, need triage
assignees: ''
---
<!--
Note: Please search to see if an issue already exists for the bug you encountered.
-->
**Describe the bug**
A clear and concise description of what the bug is.
**Task List**
[ ] I have read thoroughly the README setup and installation instructions.
[ ] If my setup is different, I have included my docker-compose file.
[ ] I have included my redacted `.env` file.
[ ] I have included information on my machine, and environment.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,32 @@
name: Feature Request
description: Request a feature that you would like for the app
title: "[Feature]: "
labels: ["feature", "need triage"]
assignees:
- ""
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request!
- type: textarea
id: feature-detail
attributes:
label: Feature detail
placeholder: Describe the feature you would like to see for the app
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Choose the platform for the feature
options:
- Web
- Mobile App
- Server
validations:
required: true

View File

@@ -8,26 +8,27 @@ on:
branches: [main]
jobs:
build_and_push_server_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main" # branch
# ref: "main" # branch
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich
uses: docker/build-push-action@v2.10.0
uses: docker/build-push-action@v3.0.0
with:
context: ./server
file: ./server/Dockerfile
@@ -37,28 +38,59 @@ jobs:
altran1502/immich-server:latest
build_and_push_microservice_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main" # branch
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Microservices
uses: docker/build-push-action@v2.10.0
with:
context: ./microservices
file: ./microservices/Dockerfile
platforms: linux/arm/v7,linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-microservices:latest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# ref: "main" # branch
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- 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 Microservices
uses: docker/build-push-action@v3.0.0
with:
context: ./microservices
file: ./microservices/Dockerfile
platforms: linux/arm/v7,linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-microservices:latest
build_and_push_web_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# ref: "main" # branch
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.0.0
with:
context: ./web
file: ./web/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
target: prod
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-web:latest

View File

@@ -12,30 +12,30 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
ref: "main"
fetch-depth: 0
- name: 'Get Previous tag'
- name: "Get Previous tag"
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: latest
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-server release
uses: docker/build-push-action@v2.10.0
uses: docker/build-push-action@v3.0.0
with:
context: ./server
file: ./server/Dockerfile
@@ -50,34 +50,73 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
ref: "main"
fetch-depth: 0
- name: 'Get Previous tag'
- name: "Get Previous tag"
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: latest
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-microservices release
uses: docker/build-push-action@v2.10.0
uses: docker/build-push-action@v3.0.0
with:
context: ./microservices
file: ./microservices/Dockerfile
platforms: linux/arm/v7,linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }}
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }}
build_and_push_web_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-web release
uses: docker/build-push-action@v3.0.0
with:
context: ./web
file: ./web/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
target: prod
tags: |
altran1502/immich-web:${{ steps.previoustag.outputs.tag }}

View File

@@ -5,7 +5,13 @@ dev-update:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans
stage:
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
test-e2e:
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich_server_test
prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans

9
NOTES.md Normal file
View File

@@ -0,0 +1,9 @@
# TODO
Server scenario with web
[ ] 1 user exist without admin right -> make admin on first check
[ ] 2 users exist without admin right -> ask user to choose which account will be the admin
[ X ] No users exist -> prompt signup form for Admin

105
README.md
View File

@@ -31,29 +31,35 @@ Loading ~4000 images/videos
## Screenshots
### Mobile client
<p align="left">
<img src="design/nsc1.png" width="150" title="Login With Custom URL">
<img src="design/nsc2.png" width="150" title="Backup Setting Info">
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
<img src="design/backup-screen.png" width="150" title="Backup Setting Info">
<img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info">
<img src="design/home-screen.jpeg" width="150" title="Home Screen">
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
<img src="design/shared-albums.png" width="150" title="Shared Albums">
<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">
</p>
# Note
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!**
This project is under heavy development, there will be continous functions, features and api changes.
This project is under heavy development, there will be continuous functions, features and api changes.
# Features
- Upload and view assets (videos/images).
- Auto Backup.
- Download asset to local device.
- Multi-user supported.
- Quick navigation with drag scroll bar.
- Auto Backup.
- Support HEIC/HEIF Backup.
- Extract and display EXIF info.
- Real-time render from multi-device upload event.
@@ -65,24 +71,31 @@ This project is under heavy development, there will be continous functions, feat
- 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
**OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc). I haven't tested with `Docker for Windows` as well as `WSL` on Windows
**OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
I haven't tested with `Docker for Windows` as well as `WSL` on Windows
*Raspberry Pi can be used but `microservices` container has to be comment out in `docker-compose` since TensorFlow has not been supported in Docker image on arm64v7 yet.*
**RAM**: At least 2GB, preffered 4GB.
**Cores**: At least 2 cores, preffered 4 cores.
**Core**: At least 2 cores, preffered 4 cores.
# Development and Testing out the application
# Getting Started
You can use docker compose for development and testing out the application, there are several services that compose Immich:
1. **NestJs** - Backend of the application
2. **PostgreSQL** - Main database of the application
3. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
4. **Nginx** - Load balancing and optimized file uploading.
5. **TensorFlow** - Object Detection and Image Classification.
2. **SvelteKit** - Web frontend of the application
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.
## Step 1: Populate .env file
@@ -101,52 +114,75 @@ Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is own
**Example**
```bash
###################################################################################
# Database
###################################################################################
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=immich
###################################################################################
# Upload File Config
###################################################################################
UPLOAD_LOCATION=<put-the-path-of-the-upload-folder-here>
###################################################################################
# JWT SECRET
###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
###################################################################################
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
MAPBOX_KEY=
###################################################################################
# WEB
###################################################################################
# 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
```
## Step 2: Start the server
To start, run
To **start**, run
```bash
docker-compose -f ./docker/docker-compose.yml up
```
If you have a few thousand photos/videos, I suggest running docker-compose with scaling option for the `immich_server` container to handle high I/O load when using fast scrolling.
If you have a few thousand photos/videos, I suggest running docker-compose with *scaling* option for the `immich_server` container to handle high I/O load when using fast scrolling.
```bash
docker-compose -f ./docker/docker-compose.yml up --scale immich_server=5
docker-compose -f ./docker/docker-compose.yml up --scale immich-server=5
```
To *update* docker-compose with newest image (if you have started the docker-compose previously)
```bash
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`
## Step 3: Register User
Use the command below on your terminal to create user as we don't have user interface for this function yet.
Access the web interface at `http://your-ip:2285` to register an admin account.
```bash
curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "testuser@email.com",
"password": "password"
}'
```
<p align="left">
<img src="design/admin-registration-form.png" width="300" title="Admin Registration">
<p/>
Additional accounts on the server can be created by the admin account.
<p align="left">
<img src="design/admin-interface.png" width="500" title="Admin User Management">
<p/>
## Step 4: Run mobile app
@@ -181,15 +217,26 @@ You can get the app on F-droid by clicking the image below.
<img src="design/ios-qr-code.png" width="200" title="Apple App Store">
<p/>
# Development
The development environment can be started from the root of the project after populating the `.env` file with the command:
```bash
make dev # required Makefile installed on the system.
```
All servers and web container are hot reload for quick feedback loop.
# Support
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**Github Sponsore**](https://github.com/sponsors/alextran1502), or one time donation with Buy Me a coffee link below.
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**Github Sponsor**](https://github.com/sponsors/alextran1502), or a one time donation with the Buy Me a coffee link below.
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/altran1502)
This is also a meaningful way to give me motivation and encounragment to continue working on the app.
This is also a meaningful way to give me motivation and encouragement to continue working on the app.
Cheer! 🎉
Cheers! 🎉
# Known Issue
@@ -197,13 +244,13 @@ Cheer! 🎉
*This is a known issue on RaspberryPi 4 arm64-v7 and incorrect Promox setup*
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
```bash
more /proc/cpuinfo | grep flags
```
If you are running virtualization in Promox, the VM doesn't have the flag enable.
If you are running virtualization in Promox, the VM doesn't have the flag enabled.
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.

BIN
design/admin-interface.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
design/backup-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
design/login-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

View File

@@ -1,15 +1,63 @@
###################################################################################
# Database
###################################################################################
DB_HOSTNAME=immich_postgres
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=immich
###################################################################################
# Redis
###################################################################################
REDIS_HOSTNAME=immich_redis
###################################################################################
# Upload File Config
###################################################################################
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# JWT SECRET
###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
###################################################################################
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
MAPBOX_KEY=
MAPBOX_KEY=
###################################################################################
# WEB
###################################################################################
# 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
# !CAUTION! THERE IS NO FORWARD SLASH AT THE END
VITE_SERVER_ENDPOINT=

16
docker/.env.test Normal file
View File

@@ -0,0 +1,16 @@
DB_HOSTNAME=immich_postgres_test
# Database
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=e2e_test
# Upload File Config
UPLOAD_LOCATION=./upload
# JWT SECRET
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
MAPBOX_KEY=

View File

@@ -1,8 +1,8 @@
version: "3.8"
services:
immich_server:
image: immich-server-dev:1.8.0
immich-server:
image: immich-server-dev:1.9.0
build:
context: ../server
dockerfile: Dockerfile
@@ -21,10 +21,10 @@ services:
- redis
- database
networks:
- immich_network
- immich-network
immich_microservices:
image: immich-microservices-dev:1.8.0
immich-microservices:
image: immich-microservices-dev:1.9.0
build:
context: ../microservices
dockerfile: Dockerfile
@@ -42,14 +42,32 @@ services:
depends_on:
- database
networks:
- immich_network
- immich-network
immich-web:
image: immich-web-dev:1.9.0
build:
context: ../web
dockerfile: Dockerfile
target: dev
command: npm run dev --host
env_file:
- .env
ports:
- 3002:3002
- 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
- immich-network
database:
container_name: immich_postgres
@@ -66,7 +84,7 @@ services:
ports:
- 5432:5432
networks:
- immich_network
- immich-network
nginx:
container_name: proxy_nginx
@@ -79,11 +97,11 @@ services:
logging:
driver: none
networks:
- immich_network
- immich-network
depends_on:
- immich_server
- immich-server
networks:
immich_network:
immich-network:
volumes:
pgdata:

View File

@@ -1,8 +1,8 @@
version: "3.8"
services:
immich_server:
image: immich-server-dev:1.8.0
immich-server:
image: immich-server-dev:1.9.0
build:
context: ../server
dockerfile: Dockerfile
@@ -19,10 +19,10 @@ services:
- redis
- database
networks:
- immich_network
- immich-network
immich_microservices:
image: immich-microservices-dev:1.8.0
immich-microservices:
image: immich-microservices-dev:1.9.0
build:
context: ../microservices
dockerfile: Dockerfile
@@ -46,13 +46,13 @@ services:
- database
- immich_server
networks:
- immich_network
- immich-network
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich_network
- immich-network
database:
container_name: immich_postgres
@@ -69,7 +69,7 @@ services:
ports:
- 5432:5432
networks:
- immich_network
- immich-network
nginx:
container_name: proxy_nginx
@@ -82,11 +82,11 @@ services:
logging:
driver: none
networks:
- immich_network
- immich-network
depends_on:
- immich_server
- immich-server
networks:
immich_network:
immich-network:
volumes:
pgdata:

View File

@@ -0,0 +1,105 @@
version: "3.8"
services:
immich-server:
image: immich-server-staging:latest
build:
context: ../server
dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3000"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-microservices:
image: immich-microservices-staging:latest
build:
context: ../microservices
dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- database
networks:
- immich-network
restart: always
immich-web:
image: immich-web-staging:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"]
build:
context: ../web
dockerfile: Dockerfile
target: prod
env_file:
- .env
ports:
- 2285:3000
networks:
- immich-network
restart: always
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich-network
restart: always
database:
container_name: immich_postgres
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
restart: always
networks:
immich-network:
volumes:
pgdata:

View File

@@ -0,0 +1,52 @@
version: "3.8"
services:
immich_server_test:
image: immich-server-dev:1.9.0
build:
context: ../server
dockerfile: Dockerfile
command: npm run test:e2e
expose:
- "3000"
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
env_file:
- .env.test
environment:
- NODE_ENV=development
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
image: postgres:14
env_file:
- .env.test
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata-test:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich_network_test
networks:
immich_network_test:
volumes:
pgdata-test:

View File

@@ -1,8 +1,8 @@
version: "3.8"
services:
immich_server:
image: altran1502/immich-server:v1.8.0_12-dev
immich-server:
image: altran1502/immich-server:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3000"
@@ -16,11 +16,11 @@ services:
- redis
- database
networks:
- immich_network
restart: unless-stopped
- immich-network
restart: always
immich_microservices:
image: altran1502/immich-microservices:v1.8.0_12-dev
immich-microservices:
image: altran1502/immich-microservices:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
@@ -33,14 +33,28 @@ services:
depends_on:
- database
networks:
- immich_network
restart: unless-stopped
- immich-network
restart: always
immich-web:
image: altran1502/immich-web:latest
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
- immich-network
restart: always
database:
container_name: immich_postgres
@@ -57,8 +71,9 @@ services:
ports:
- 5432:5432
networks:
- immich_network
- immich-network
restart: always
nginx:
container_name: proxy_nginx
image: nginx:latest
@@ -70,11 +85,12 @@ services:
logging:
driver: none
networks:
- immich_network
- immich-network
depends_on:
- immich_server
- immich-server
restart: always
networks:
immich_network:
immich-network:
volumes:
pgdata:

View File

@@ -41,6 +41,6 @@ server {
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich_server:3000;
proxy_pass http://immich-server:3000;
}
}

View File

@@ -2,7 +2,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
host: 'immich_postgres',
host: process.env.DB_HOSTNAME || 'immich_postgres',
port: 5432,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,

View File

@@ -5,7 +5,7 @@ import { ImageClassifierService } from './image-classifier.service';
export class ImageClassifierController {
constructor(
private readonly imageClassifierService: ImageClassifierService,
) {}
) { }
@Post('/tagImage')
async tagImage(@Body('thumbnailPath') thumbnailPath: string) {

View File

@@ -1,11 +1,12 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ObjectDetectionService } from './object-detection.service';
import { Logger } from '@nestjs/common';
@Controller('object-detection')
export class ObjectDetectionController {
constructor(
private readonly objectDetectionService: ObjectDetectionService,
) {}
) { }
@Post('/detectObject')
async detectObject(@Body('thumbnailPath') thumbnailPath: string) {

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich">
<application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher">
<application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user

View File

@@ -1,16 +1,21 @@
// 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 io.flutter.app.FlutterApplication}, adding multidex support.
* Extension of {@link android.app.Application}, adding multidex support.
*/
public class FlutterMultiDexApplication extends FlutterApplication {
public class FlutterMultiDexApplication extends Application {
@Override
@CallSuper
protected void attachBaseContext(Context base) {

View File

@@ -0,0 +1,2 @@
* New Feature - Selection backup. User can now select a combination of albums to be included or excluded during the backup process, and only unique photos, and videos that are not overlapping between the two groups will be backup.
* Bug fix - Show correct count of backup and remainder assets.

View File

@@ -0,0 +1 @@
* Hotfix: Permission is being requested now when open backup screen on Android10

View File

@@ -0,0 +1 @@
* User can now upload profile picture from the home page control drawer.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -9,6 +9,8 @@ PODS:
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- image_picker_ios (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_ios (0.0.1):
@@ -30,6 +32,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
@@ -50,6 +53,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_udid/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_ios:
@@ -66,10 +71,11 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<string>1.10.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>14</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
@@ -43,6 +43,12 @@
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -68,5 +74,7 @@
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
</dict>
</plist>

View File

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

View File

@@ -21,7 +21,7 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
[bundle exec] fastlane ios beta
```
iOS deployment
iOS Beta
----

View File

@@ -5,27 +5,12 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000332">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000946">
</testcase>
<testcase classname="fastlane.lanes" name="1: latest_testflight_build_number" time="4.608292">
</testcase>
<testcase classname="fastlane.lanes" name="2: increment_build_number" time="0.747162">
</testcase>
<testcase classname="fastlane.lanes" name="3: build_app" time="88.727281">
</testcase>
<testcase classname="fastlane.lanes" name="4: upload_to_testflight" time="7.79397">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="16.3225">
</testcase>

View File

@@ -9,3 +9,7 @@ const String serverEndpointKey = 'immichBoxServerEndpoint';
// Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox";
const String savedLoginInfoKey = "immichSavedLoginInfoKey";
// Backup Info
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox";
const String backupInfoKey = "immichBackupAlbumInfoKey";

View File

@@ -3,12 +3,13 @@ import 'package:flutter/services.dart';
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/login/models/hive_saved_login_info.model.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/shared/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.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';
@@ -16,9 +17,13 @@ import 'constants/hive_box.dart';
void main() async {
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
@@ -71,7 +76,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
}
Future<void> initApp() async {
WidgetsBinding.instance?.addObserver(this);
WidgetsBinding.instance.addObserver(this);
}
@override
@@ -82,7 +87,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
@override
void dispose() {
WidgetsBinding.instance?.removeObserver(this);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

View File

@@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'package:photo_manager/photo_manager.dart';
class AvailableAlbum {
final AssetPathEntity albumEntity;
final Uint8List? thumbnailData;
AvailableAlbum({
required this.albumEntity,
this.thumbnailData,
});
AvailableAlbum copyWith({
AssetPathEntity? albumEntity,
Uint8List? thumbnailData,
}) {
return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity,
thumbnailData: thumbnailData ?? this.thumbnailData,
);
}
@override
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;
}
@override
int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode;
}

View File

@@ -0,0 +1,88 @@
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart';
enum BackUpProgressEnum { idle, inProgress, done }
class BackUpState extends Equatable {
// enum
final BackUpProgressEnum backupProgress;
final List<String> allAssetOnDatabase;
final double progressInPercentage;
final CancelToken cancelToken;
final ServerInfo serverInfo;
/// All available albums on the device
final List<AvailableAlbum> availableAlbums;
final Set<AssetPathEntity> selectedBackupAlbums;
final Set<AssetPathEntity> excludedBackupAlbums;
/// Assets that are not overlapping in selected backup albums and excluded backup albums
final Set<AssetEntity> allUniqueAssets;
/// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds;
const BackUpState({
required this.backupProgress,
required this.allAssetOnDatabase,
required this.progressInPercentage,
required this.cancelToken,
required this.serverInfo,
required this.availableAlbums,
required this.selectedBackupAlbums,
required this.excludedBackupAlbums,
required this.allUniqueAssets,
required this.selectedAlbumsBackupAssetsIds,
});
BackUpState copyWith({
BackUpProgressEnum? backupProgress,
List<String>? allAssetOnDatabase,
double? progressInPercentage,
CancelToken? cancelToken,
ServerInfo? serverInfo,
List<AvailableAlbum>? availableAlbums,
Set<AssetPathEntity>? selectedBackupAlbums,
Set<AssetPathEntity>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds,
}) {
return BackUpState(
backupProgress: backupProgress ?? this.backupProgress,
allAssetOnDatabase: allAssetOnDatabase ?? this.allAssetOnDatabase,
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
availableAlbums: availableAlbums ?? this.availableAlbums,
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
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)';
}
@override
List<Object> get props {
return [
backupProgress,
allAssetOnDatabase,
progressInPercentage,
cancelToken,
serverInfo,
availableAlbums,
selectedBackupAlbums,
excludedBackupAlbums,
allUniqueAssets,
selectedAlbumsBackupAssetsIds,
];
}
}

View File

@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hive/hive.dart';
part 'hive_backup_albums.model.g.dart';
@HiveType(typeId: 1)
class HiveBackupAlbums {
@HiveField(0)
List<String> selectedAlbumIds;
@HiveField(1)
List<String> excludedAlbumsIds;
HiveBackupAlbums({
required this.selectedAlbumIds,
required this.excludedAlbumsIds,
});
@override
String toString() => 'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)';
HiveBackupAlbums copyWith({
List<String>? selectedAlbumIds,
List<String>? excludedAlbumsIds,
}) {
return HiveBackupAlbums(
selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'selectedAlbumIds': selectedAlbumIds});
result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
return result;
}
factory HiveBackupAlbums.fromMap(Map<String, dynamic> map) {
return HiveBackupAlbums(
selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
);
}
String toJson() => json.encode(toMap());
factory HiveBackupAlbums.fromJson(String source) => HiveBackupAlbums.fromMap(json.decode(source));
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is HiveBackupAlbums &&
listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
listEquals(other.excludedAlbumsIds, excludedAlbumsIds);
}
@override
int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode;
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'hive_backup_albums.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
@override
final int typeId = 1;
@override
HiveBackupAlbums read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return HiveBackupAlbums(
selectedAlbumIds: (fields[0] as List).cast<String>(),
excludedAlbumsIds: (fields[1] as List).cast<String>(),
);
}
@override
void write(BinaryWriter writer, HiveBackupAlbums obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.selectedAlbumIds)
..writeByte(1)
..write(obj.excludedAlbumsIds);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HiveBackupAlbumsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,347 @@
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/services/backup.service.dart';
import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier({this.ref})
: super(
BackUpState(
backupProgress: BackUpProgressEnum.idle,
allAssetOnDatabase: const [],
progressInPercentage: 0,
cancelToken: CancelToken(),
serverInfo: ServerInfo(
diskAvailable: "0",
diskAvailableRaw: 0,
diskSize: "0",
diskSizeRaw: 0,
diskUsagePercentage: 0.0,
diskUse: "0",
diskUseRaw: 0,
),
availableAlbums: const [],
selectedBackupAlbums: const {},
excludedBackupAlbums: const {},
allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {},
),
);
Ref? ref;
final BackupService _backupService = BackupService();
final ServerInfoService _serverInfoService = ServerInfoService();
///
/// UI INTERACTION
///
/// Album selection
/// Due to the overlapping assets across multiple albums on the device
/// We have method to include and exclude albums
/// The total unique assets will be used for backing mechanism
///
void addAlbumForBackup(AssetPathEntity album) {
if (state.excludedBackupAlbums.contains(album)) {
removeExcludedAlbumForBackup(album);
}
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
_updateBackupAssetCount();
}
void addExcludedAlbumForBackup(AssetPathEntity album) {
if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album);
}
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
_updateBackupAssetCount();
}
void removeAlbumForBackup(AssetPathEntity album) {
Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
currentSelectedAlbums.removeWhere((a) => a == album);
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
_updateBackupAssetCount();
}
void removeExcludedAlbumForBackup(AssetPathEntity album) {
Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
currentExcludedAlbums.removeWhere((a) => a == album);
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
_updateBackupAssetCount();
}
///
/// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage
/// 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 {
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
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);
if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first;
var thumbnailData = await thumbnailAsset.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
}
availableAlbums.add(availableAlbum);
}
state = state.copyWith(availableAlbums: availableAlbums);
// Put persistent storage info into local state of the app
// Get local storage on selected backup album
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
backupInfoKey,
defaultValue: HiveBackupAlbums(
selectedAlbumIds: [],
excludedAlbumsIds: [],
),
);
if (backupAlbumInfo == null) {
debugPrint("[ERROR] getting Hive backup album infomation");
return;
}
// First time backup - set isAll album is the default one for backup.
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
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);
AssetPathEntity albumHasAllAssets = list.first;
backupAlbumInfoBox.put(
backupInfoKey,
HiveBackupAlbums(
selectedAlbumIds: [albumHasAllAssets.id],
excludedAlbumsIds: [],
),
);
backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey);
}
// Generate AssetPathEntity from id to add to local state
try {
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
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});
}
} catch (e) {
debugPrint("[ERROR] Failed to generate album from id $e");
}
}
///
/// From all the selected and albums assets
/// 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 {
Set<AssetEntity> assetsFromSelectedAlbums = {};
Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) {
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);
assetsFromExcludedAlbums.addAll(assets);
}
Set<AssetEntity> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
List<String> allAssetOnDatabase = await _backupService.getDeviceBackupAsset();
// Find asset that were backup from selected albums
Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId));
if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetOnDatabase: allAssetOnDatabase,
allUniqueAssets: {},
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
return;
} else {
state = state.copyWith(
allAssetOnDatabase: allAssetOnDatabase,
allUniqueAssets: allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
}
// Save to persistent storage
_updatePersistentAlbumsSelection();
}
///
/// Get all necessary information for calculating the available albums,
/// which albums are selected or excluded
/// and then update the UI according to those information
///
void getBackupInfo() async {
await getBackupAlbumsInfo();
_updateServerInfo();
_updateBackupAssetCount();
}
///
/// Save user selection of selected albums and excluded albums to
/// Hive database
///
void _updatePersistentAlbumsSelection() {
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
backupAlbumInfoBox.put(
backupInfoKey,
HiveBackupAlbums(
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
),
);
}
///
/// Invoke backup process
///
void startBackupProcess() async {
_updateServerInfo();
_updateBackupAssetCount();
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
var authResult = await PhotoManager.requestPermissionExtend();
if (authResult.isAuth) {
await PhotoManager.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process");
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
return;
}
Set<AssetEntity> assetsWillBeBackup = state.allUniqueAssets;
// Remove item that has already been backed up
for (var assetId in state.allAssetOnDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
}
if (assetsWillBeBackup.isEmpty) {
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
}
// Perform Backup
state = state.copyWith(cancelToken: CancelToken());
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress);
} else {
PhotoManager.openSetting();
}
}
void cancelBackup() {
state.cancelToken.cancel('Cancel Backup');
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, deviceAssetId},
allAssetOnDatabase: [...state.allAssetOnDatabase, deviceAssetId]);
if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) {
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
}
_updateServerInfo();
}
void _onUploadProgress(int sent, int total) {
state = state.copyWith(progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
}
void _updateServerInfo() async {
var serverInfo = await _serverInfoService.getServerInfo();
// Update server info
state = state.copyWith(
serverInfo: ServerInfo(
diskSize: serverInfo.diskSize,
diskUse: serverInfo.diskUse,
diskAvailable: serverInfo.diskAvailable,
diskSizeRaw: serverInfo.diskSizeRaw,
diskUseRaw: serverInfo.diskUseRaw,
diskAvailableRaw: serverInfo.diskAvailableRaw,
diskUsagePercentage: serverInfo.diskUsagePercentage,
),
);
}
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");
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;
}
}
}
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(ref: ref);
});

View File

@@ -26,7 +26,7 @@ class BackupService {
return result.cast<String>();
}
backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgress) async {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
@@ -73,7 +73,7 @@ class BackupService {
});
// Build thumbnail multipart data
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(720, 1280));
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
if (thumbnailData != null) {
thumbnailUploadData = MultipartFile.fromBytes(
List.from(thumbnailData),

View File

@@ -0,0 +1,185 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:photo_manager/photo_manager.dart';
class AlbumInfoCard extends HookConsumerWidget {
final Uint8List? imageData;
final AssetPathEntity albumInfo;
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);
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) {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text(
"INCLUDED",
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: Theme.of(context).primaryColor,
);
} else if (isExcluded) {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text(
"EXCLUDED",
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: Colors.red[300],
);
}
return Container();
}
_buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
return excludedFilter;
} else {
return unselectedFilter;
}
}
return GestureDetector(
onTap: () {
HapticFeedback.selectionClick();
if (isSelected) {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
ImmichToast.show(
context: context,
msg: "Cannot remove the only album",
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.watch(backupProvider.notifier).removeAlbumForBackup(albumInfo);
} else {
ref.watch(backupProvider.notifier).addAlbumForBackup(albumInfo);
}
},
onDoubleTap: () {
HapticFeedback.selectionClick();
if (isExcluded) {
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(albumInfo);
} else {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 &&
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo)) {
ImmichToast.show(
context: context,
msg: "Cannot exclude the only album",
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.watch(backupProvider.notifier).addExcludedAlbumForBackup(albumInfo);
}
},
child: Card(
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // if you need this
side: const BorderSide(
color: Color(0xFFC9C9C9),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
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,
fit: BoxFit.cover,
),
),
child: null,
),
Positioned(bottom: 10, left: 25, child: _buildSelectedTextBox())
],
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 140,
child: Padding(
padding: const EdgeInsets.only(left: 25.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
albumInfo.name,
style: TextStyle(
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]),
),
)
],
),
),
),
IconButton(
onPressed: () {
AutoRouter.of(context).push(AlbumPreviewRoute(album: albumInfo));
},
icon: Icon(
Icons.image_outlined,
color: Theme.of(context).primaryColor,
size: 24,
),
splashRadius: 25,
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
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);
@override
Widget build(BuildContext context) {
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 15,
isThreeLine: true,
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
subtitle,
style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
info,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const Text("assets"),
],
),
),
);
}
}

View File

@@ -0,0 +1,84 @@
import 'dart:typed_data';
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/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
class AlbumPreviewPage extends HookConsumerWidget {
final AssetPathEntity album;
const AlbumPreviewPage({Key? key, required this.album}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<AssetEntity>>([]);
_getAssetsInAlbum() async {
assets.value = await album.getAssetListRange(start: 0, end: album.assetCount);
}
useEffect(() {
_getAssetsInAlbum();
return null;
}, []);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Column(
children: [
Text(
"${album.name} (${album.assetCount})",
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
"ID ${album.id}",
style: TextStyle(fontSize: 10, color: Colors.grey[600], fontWeight: FontWeight.bold),
),
),
],
),
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
Future<Uint8List?> thumbData =
assets.value[index].thumbnailDataWithSize(const ThumbnailSize(200, 200), quality: 50);
return FutureBuilder<Uint8List?>(
future: thumbData,
builder: ((context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
width: 100,
height: 100,
fit: BoxFit.cover,
);
}
return const SizedBox(
width: 100,
height: 100,
child: ImmichLoadingIndicator(),
);
}),
);
},
),
);
}
}

View File

@@ -0,0 +1,244 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
useEffect(() {
ref.read(backupProvider.notifier).getBackupAlbumsInfo();
return null;
}, []);
_buildAlbumSelectionList() {
if (availableAlbums.isEmpty) {
return const Center(
child: ImmichLoadingIndicator(),
);
}
return SizedBox(
height: 265,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: availableAlbums.length,
physics: const BouncingScrollPhysics(),
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),
);
}),
),
);
}
_buildSelectedAlbumNameChip() {
return selectedBackupAlbums.map((album) {
void removeSelection() {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
ImmichToast.show(
context: context,
msg: "Cannot remove the only album",
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.watch(backupProvider.notifier).removeAlbumForBackup(album);
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: removeSelection,
child: Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: Text(
album.name,
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: Theme.of(context).primaryColor,
deleteIconColor: Colors.white,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
_buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) {
void removeSelection() {
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album);
}
return GestureDetector(
onTap: removeSelection,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: Text(
album.name,
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: Colors.red[300],
deleteIconColor: Colors.white,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: const Text(
"Select Albums",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
elevation: 0,
),
body: ListView(
physics: const ClampingScrollPhysics(),
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text(
"Selection Info",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [..._buildSelectedAlbumNameChip(), ..._buildExcludedAlbumNameChip()],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Card(
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(255, 235, 235, 235),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
children: [
ListTile(
visualDensity: VisualDensity.compact,
title: Text(
"Total unique assets",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.grey[700]),
),
trailing: Text(
ref.watch(backupProvider).allUniqueAssets.length.toString(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
),
ListTile(
title: Text(
"Albums on device (${availableAlbums.length.toString()})",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"Tap to include, double tap to exclude",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: Theme.of(context).primaryColor,
),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 5,
title: Text(
'Selection Info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
content: SingleChildScrollView(
child: ListBody(
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]),
),
],
),
),
);
},
);
},
),
),
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: _buildAlbumSelectionList(),
),
],
),
);
}
}

View File

@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
import 'package:percent_indicator/linear_percent_indicator.dart';
class BackupControllerPage extends HookConsumerWidget {
@@ -14,13 +16,13 @@ class BackupControllerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState _backupState = ref.watch(backupProvider);
BackUpState backupState = ref.watch(backupProvider);
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
bool shouldBackup = _backupState.totalAssetCount - _backupState.assetOnDatabase == 0 ? false : true;
bool shouldBackup =
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
useEffect(() {
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
ref.read(backupProvider.notifier).getBackupInfo();
}
@@ -43,16 +45,20 @@ class BackupControllerPage extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LinearPercentIndicator(
Padding(
padding: const EdgeInsets.only(top: 8.0),
lineHeight: 5.0,
percent: _backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
child: LinearPercentIndicator(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 6.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
),
),
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'),
),
],
),
@@ -104,18 +110,120 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
Widget _buildSelectedAlbumName() {
var text = "Selected: ";
var albums = ref.watch(backupProvider).selectedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (All), ";
} else {
text += "${album.name}, ";
}
}
return Padding(
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),
),
);
} else {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"None selected",
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
),
);
}
}
Widget _buildExcludedAlbumName() {
var text = "Excluded: ";
var albums = ref.watch(backupProvider).excludedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
text += "${album.name}, ";
}
return Padding(
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),
),
);
} else {
return Container();
}
}
_buildFolderSelectionTile() {
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 15,
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",
style: TextStyle(color: Color(0xFF808080), fontSize: 12),
),
_buildSelectedAlbumName(),
_buildExcludedAlbumName()
],
),
),
trailing: OutlinedButton(
onPressed: () {
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
},
child: const Padding(
padding: EdgeInsets.symmetric(
vertical: 16.0,
),
child: Text(
"Select",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
);
}
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text(
"Backup",
style: TextStyle(fontWeight: FontWeight.bold),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
AutoRouter.of(context).pop(true);
},
icon: const Icon(Icons.arrow_back_ios_rounded)),
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
)),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
@@ -129,20 +237,21 @@ class BackupControllerPage extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
_buildFolderSelectionTile(),
BackupInfoCard(
title: "Total",
subtitle: "All images and videos on the device",
info: "${_backupState.totalAssetCount}",
subtitle: "All unique photos and videos from selected albums",
info: "${backupState.allUniqueAssets.length}",
),
BackupInfoCard(
title: "Backup",
subtitle: "Images and videos of the device that are backup on server",
info: "${_backupState.assetOnDatabase}",
subtitle: "Photos and videos from selected albums that are backup",
info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
),
BackupInfoCard(
title: "Remainder",
subtitle: "Images and videos that has not been backing up",
info: "${_backupState.totalAssetCount - _backupState.assetOnDatabase}",
subtitle: "Photos and videos that has not been backing up from selected albums",
info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
),
const Divider(),
_buildBackupController(),
@@ -152,14 +261,14 @@ class BackupControllerPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"Asset that were being backup: ${_backupState.backingUpAssetCount} [${_backupState.progressInPercentage.toStringAsFixed(0)}%]"),
"Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(children: [
const Text("Backup Progress:"),
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
_backupState.backupProgress == BackUpProgressEnum.inProgress
backupState.backupProgress == BackUpProgressEnum.inProgress
? const CircularProgressIndicator.adaptive()
: const Text("Done"),
]),
@@ -167,7 +276,7 @@ class BackupControllerPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
child: _backupState.backupProgress == BackUpProgressEnum.inProgress
child: backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(primary: Colors.red[300]),
onPressed: () {
@@ -191,50 +300,3 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
}
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);
@override
Widget build(BuildContext context) {
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 15,
isThreeLine: true,
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
subtitle,
style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
info,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const Text("assets"),
],
),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
enum UploadProfileStatus {
idle,
loading,
success,
failure,
}
class UploadProfileImageState {
// enum
final UploadProfileStatus status;
final String profileImagePath;
UploadProfileImageState({
required this.status,
required this.profileImagePath,
});
UploadProfileImageState copyWith({
UploadProfileStatus? status,
String? profileImagePath,
}) {
return UploadProfileImageState(
status: status ?? this.status,
profileImagePath: profileImagePath ?? this.profileImagePath,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'status': status.index});
result.addAll({'profileImagePath': profileImagePath});
return result;
}
factory UploadProfileImageState.fromMap(Map<String, dynamic> map) {
return UploadProfileImageState(
status: UploadProfileStatus.values[map['status'] ?? 0],
profileImagePath: map['profileImagePath'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory UploadProfileImageState.fromJson(String source) => UploadProfileImageState.fromMap(json.decode(source));
@override
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;
}
@override
int get hashCode => status.hashCode ^ profileImagePath.hashCode;
}
class UploadProfileImageNotifier extends StateNotifier<UploadProfileImageState> {
UploadProfileImageNotifier()
: super(UploadProfileImageState(
profileImagePath: '',
status: UploadProfileStatus.idle,
));
Future<bool> upload(XFile file) async {
state = state.copyWith(status: UploadProfileStatus.loading);
var res = await UserService().uploadProfileImage(file);
if (res != null) {
debugPrint("Succesfully upload profile image");
state = state.copyWith(status: UploadProfileStatus.success, profileImagePath: res.profileImagePath);
return true;
}
state = state.copyWith(status: UploadProfileStatus.failure);
return false;
}
}
final uploadProfileImageProvider =
StateNotifierProvider<UploadProfileImageNotifier, UploadProfileImageState>(((ref) => UploadProfileImageNotifier()));

View File

@@ -12,7 +12,7 @@ class ImageGrid extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisCount: 4,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),

View File

@@ -5,9 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class ImmichSliverAppBar extends ConsumerWidget {
@@ -29,7 +29,6 @@ class ImmichSliverAppBar extends ConsumerWidget {
floating: true,
pinned: false,
snap: false,
// backgroundColor: Colors.grey[200],
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
leading: Builder(
builder: (BuildContext context) {
@@ -131,7 +130,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
? Positioned(
bottom: 5,
child: Text(
_backupState.backingUpAssetCount.toString(),
(_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length)
.toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
)

View File

@@ -1,25 +1,33 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package: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/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';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.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/ui/immich_loading_indicator.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'dart:math';
class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key);
@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;
final appInfo = useState({});
var dummmy = Random().nextInt(1024);
_getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
@@ -30,19 +38,74 @@ class ProfileDrawer extends HookConsumerWidget {
};
}
_buildUserProfileImage() {
if (_authState.profileImagePath.isEmpty) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (_authState.profileImagePath.isNotEmpty) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage('$endpoint/user/profile-image/${_authState.userId}?d=${dummmy++}'),
backgroundColor: Colors.transparent,
);
} else {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
}
if (uploadProfileImageStatus == UploadProfileStatus.success) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage('$endpoint/user/profile-image/${_authState.userId}?d=${dummmy++}'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const ImmichLoadingIndicator();
}
return Container();
}
_pickUserProfileImage() async {
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);
if (success) {
ref
.watch(authenticationProvider.notifier)
.updateUserProfileImagePath(ref.read(uploadProfileImageProvider).profileImagePath);
}
}
}
useEffect(() {
_getPackageInfo();
_buildUserProfileImage();
return null;
}, []);
return Drawer(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(5),
bottomRight: Radius.circular(5),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -51,22 +114,60 @@ class ProfileDrawer extends HookConsumerWidget {
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: BoxDecoration(
color: Colors.grey[200],
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color.fromARGB(255, 216, 219, 238), Color.fromARGB(255, 226, 230, 231)],
begin: Alignment.centerRight,
end: Alignment.centerLeft,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 50,
filterQuality: FilterQuality.high,
Stack(
clipBehavior: Clip.none,
children: [
_buildUserProfileImage(),
Positioned(
bottom: 0,
right: -5,
child: GestureDetector(
onTap: _pickUserProfileImage,
child: Material(
color: Colors.grey[50],
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(
Icons.edit,
color: Theme.of(context).primaryColor,
size: 14,
),
),
),
),
),
],
),
const Padding(padding: EdgeInsets.all(8)),
Text(
_authState.userEmail,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
"${_authState.firstName} ${_authState.lastName}",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
_authState.userEmail,
style: TextStyle(color: Colors.grey[800], fontSize: 12),
),
)
],
),
@@ -97,7 +198,15 @@ class ProfileDrawer extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 0,
color: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(101, 201, 201, 201),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(

View File

@@ -13,7 +13,6 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key);
@@ -37,6 +36,19 @@ class HomePage extends HookConsumerWidget {
ref.read(assetProvider.notifier).getAllAsset();
}
_buildSelectedItemCountIndicator() {
return isMultiSelectEnable
? DisableMultiSelectButton(
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
selectedItemCount: homePageState.selectedItems.length,
)
: Container();
}
_buildBottomAppBar() {
return isMultiSelectEnable ? const ControlBottomAppBar() : Container();
}
Widget _buildBody() {
if (assetGroupByDateTime.isNotEmpty) {
int? lastMonth;
@@ -70,49 +82,51 @@ class HomePage extends HookConsumerWidget {
});
}
_buildSliverAppBar() {
return isMultiSelectEnable
? const SliverToBoxAdapter(
child: SizedBox(
height: 70,
child: null,
),
)
: ImmichSliverAppBar(
onPopBack: reloadAllAsset,
);
}
return SafeArea(
bottom: !isMultiSelectEnable,
top: !isMultiSelectEnable,
child: Stack(
children: [
DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
CustomScrollView(
slivers: [
_buildSliverAppBar(),
],
),
Padding(
padding: const EdgeInsets.only(top: 50.0),
child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController,
slivers: [
SliverAnimatedSwitcher(
child: isMultiSelectEnable
? const SliverToBoxAdapter(
child: SizedBox(
height: 70,
child: null,
),
)
: ImmichSliverAppBar(
onPopBack: reloadAllAsset,
),
duration: const Duration(milliseconds: 350),
),
..._imageGridGroup
],
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: _scrollController,
slivers: [
..._imageGridGroup,
],
),
),
),
isMultiSelectEnable
? DisableMultiSelectButton(
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
selectedItemCount: homePageState.selectedItems.length,
)
: Container(),
isMultiSelectEnable ? const ControlBottomAppBar() : Container(),
_buildSelectedItemCountIndicator(),
_buildBottomAppBar(),
],
),
);
}
return Scaffold(
// key: _scaffoldKey,
drawer: const ProfileDrawer(),
body: _buildBody(),
);

View File

@@ -8,6 +8,11 @@ class AuthenticationState {
final String userId;
final String userEmail;
final bool isAuthenticated;
final String firstName;
final String lastName;
final bool isAdmin;
final bool isFirstLogin;
final String profileImagePath;
final DeviceInfoRemote deviceInfo;
AuthenticationState({
@@ -16,6 +21,11 @@ class AuthenticationState {
required this.userId,
required this.userEmail,
required this.isAuthenticated,
required this.firstName,
required this.lastName,
required this.isAdmin,
required this.isFirstLogin,
required this.profileImagePath,
required this.deviceInfo,
});
@@ -25,6 +35,11 @@ class AuthenticationState {
String? userId,
String? userEmail,
bool? isAuthenticated,
String? firstName,
String? lastName,
bool? isAdmin,
bool? isFirstLoggedIn,
String? profileImagePath,
DeviceInfoRemote? deviceInfo,
}) {
return AuthenticationState(
@@ -33,24 +48,36 @@ class AuthenticationState {
userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
isAdmin: isAdmin ?? this.isAdmin,
isFirstLogin: isFirstLoggedIn ?? isFirstLogin,
profileImagePath: profileImagePath ?? this.profileImagePath,
deviceInfo: deviceInfo ?? this.deviceInfo,
);
}
@override
String toString() {
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, deviceInfo: $deviceInfo)';
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() {
return {
'deviceId': deviceId,
'deviceType': deviceType,
'userId': userId,
'userEmail': userEmail,
'isAuthenticated': isAuthenticated,
'deviceInfo': deviceInfo.toMap(),
};
final result = <String, dynamic>{};
result.addAll({'deviceId': deviceId});
result.addAll({'deviceType': deviceType});
result.addAll({'userId': userId});
result.addAll({'userEmail': userEmail});
result.addAll({'isAuthenticated': isAuthenticated});
result.addAll({'firstName': firstName});
result.addAll({'lastName': lastName});
result.addAll({'isAdmin': isAdmin});
result.addAll({'isFirstLogin': isFirstLogin});
result.addAll({'profileImagePath': profileImagePath});
result.addAll({'deviceInfo': deviceInfo.toMap()});
return result;
}
factory AuthenticationState.fromMap(Map<String, dynamic> map) {
@@ -60,6 +87,11 @@ class AuthenticationState {
userId: map['userId'] ?? '',
userEmail: map['userEmail'] ?? '',
isAuthenticated: map['isAuthenticated'] ?? false,
firstName: map['firstName'] ?? '',
lastName: map['lastName'] ?? '',
isAdmin: map['isAdmin'] ?? false,
isFirstLogin: map['isFirstLogin'] ?? false,
profileImagePath: map['profileImagePath'] ?? '',
deviceInfo: DeviceInfoRemote.fromMap(map['deviceInfo']),
);
}
@@ -78,6 +110,11 @@ class AuthenticationState {
other.userId == userId &&
other.userEmail == userEmail &&
other.isAuthenticated == isAuthenticated &&
other.firstName == firstName &&
other.lastName == lastName &&
other.isAdmin == isAdmin &&
other.isFirstLogin == isFirstLogin &&
other.profileImagePath == profileImagePath &&
other.deviceInfo == deviceInfo;
}
@@ -88,6 +125,11 @@ class AuthenticationState {
userId.hashCode ^
userEmail.hashCode ^
isAuthenticated.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
isAdmin.hashCode ^
isFirstLogin.hashCode ^
profileImagePath.hashCode ^
deviceInfo.hashCode;
}
}

View File

@@ -4,31 +4,58 @@ class LogInReponse {
final String accessToken;
final String userId;
final String userEmail;
final String firstName;
final String lastName;
final String profileImagePath;
final bool isAdmin;
final bool isFirstLogin;
LogInReponse({
required this.accessToken,
required this.userId,
required this.userEmail,
required this.firstName,
required this.lastName,
required this.profileImagePath,
required this.isAdmin,
required this.isFirstLogin,
});
LogInReponse copyWith({
String? accessToken,
String? userId,
String? userEmail,
String? firstName,
String? lastName,
String? profileImagePath,
bool? isAdmin,
bool? isFirstLogin,
}) {
return LogInReponse(
accessToken: accessToken ?? this.accessToken,
userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
profileImagePath: profileImagePath ?? this.profileImagePath,
isAdmin: isAdmin ?? this.isAdmin,
isFirstLogin: isFirstLogin ?? this.isFirstLogin,
);
}
Map<String, dynamic> toMap() {
return {
'accessToken': accessToken,
'userId': userId,
'userEmail': userEmail,
};
final result = <String, dynamic>{};
result.addAll({'accessToken': accessToken});
result.addAll({'userId': userId});
result.addAll({'userEmail': userEmail});
result.addAll({'firstName': firstName});
result.addAll({'lastName': lastName});
result.addAll({'profileImagePath': profileImagePath});
result.addAll({'isAdmin': isAdmin});
result.addAll({'isFirstLogin': isFirstLogin});
return result;
}
factory LogInReponse.fromMap(Map<String, dynamic> map) {
@@ -36,6 +63,11 @@ class LogInReponse {
accessToken: map['accessToken'] ?? '',
userId: map['userId'] ?? '',
userEmail: map['userEmail'] ?? '',
firstName: map['firstName'] ?? '',
lastName: map['lastName'] ?? '',
profileImagePath: map['profileImagePath'] ?? '',
isAdmin: map['isAdmin'] ?? false,
isFirstLogin: map['isFirstLogin'] ?? false,
);
}
@@ -44,7 +76,9 @@ class LogInReponse {
factory LogInReponse.fromJson(String source) => LogInReponse.fromMap(json.decode(source));
@override
String toString() => 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail)';
String toString() {
return 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail, firstName: $firstName, lastName: $lastName, profileImagePath: $profileImagePath, isAdmin: $isAdmin, isFirstLogin: $isFirstLogin)';
}
@override
bool operator ==(Object other) {
@@ -53,9 +87,23 @@ class LogInReponse {
return other is LogInReponse &&
other.accessToken == accessToken &&
other.userId == userId &&
other.userEmail == userEmail;
other.userEmail == userEmail &&
other.firstName == firstName &&
other.lastName == lastName &&
other.profileImagePath == profileImagePath &&
other.isAdmin == isAdmin &&
other.isFirstLogin == isFirstLogin;
}
@override
int get hashCode => accessToken.hashCode ^ userId.hashCode ^ userEmail.hashCode;
int get hashCode {
return accessToken.hashCode ^
userId.hashCode ^
userEmail.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode ^
isFirstLogin.hashCode;
}
}

View File

@@ -6,7 +6,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/models/login_response.model.dart';
import 'package:immich_mobile/shared/services/backup.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart';
@@ -17,9 +17,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationState(
deviceId: "",
deviceType: "",
isAuthenticated: false,
userId: "",
userEmail: "",
firstName: '',
lastName: '',
profileImagePath: '',
isAdmin: false,
isFirstLogin: false,
isAuthenticated: false,
deviceInfo: DeviceInfoRemote(
id: 0,
userId: "",
@@ -76,6 +81,11 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
isAuthenticated: true,
userId: payload.userId,
userEmail: payload.userEmail,
firstName: payload.firstName,
lastName: payload.lastName,
profileImagePath: payload.profileImagePath,
isAdmin: payload.isAdmin,
isFirstLoggedIn: payload.isFirstLogin,
);
if (isSavedLoginInfo) {
@@ -114,9 +124,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
state = AuthenticationState(
deviceId: "",
deviceType: "",
isAuthenticated: false,
userId: "",
userEmail: "",
firstName: '',
lastName: '',
profileImagePath: '',
isFirstLogin: false,
isAuthenticated: false,
isAdmin: false,
deviceInfo: DeviceInfoRemote(
id: 0,
userId: "",
@@ -139,6 +154,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType);
state = state.copyWith(deviceInfo: deviceInfoRemote);
}
updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
}
final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {

View File

@@ -7,7 +7,7 @@ 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/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class LoginForm extends HookConsumerWidget {

View File

@@ -138,11 +138,13 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
isScrollControlled: false,
context: context,
builder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildBottomSheetActionButton(),
],
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildBottomSheetActionButton(),
],
),
);
},
);

View File

@@ -215,31 +215,29 @@ class AlbumViewerPage extends HookConsumerWidget {
onTap: () {
titleFocusNode.unfocus();
},
child: Stack(children: [
DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: _scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: _scrollController,
slivers: [
_buildHeader(albumInfo),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: immichBackgroundColor,
child: _buildControlButton(albumInfo),
),
slivers: [
_buildHeader(albumInfo),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: immichBackgroundColor,
child: _buildControlButton(albumInfo),
),
),
_buildImageGrid(albumInfo)
],
),
),
_buildImageGrid(albumInfo)
],
),
]),
),
);
}

View File

@@ -1,5 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
@@ -14,10 +16,11 @@ import 'package:immich_mobile/modules/sharing/views/select_user_for_sharing_page
import 'package:immich_mobile/modules/sharing/views/sharing_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:photo_manager/photo_manager.dart';
part 'router.gr.dart';
@@ -55,6 +58,8 @@ part 'router.gr.dart';
guards: [AuthGuard],
transitionsBuilder: TransitionsBuilders.slideBottom,
),
AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
],
)
class AppRouter extends _$AppRouter {

View File

@@ -93,6 +93,16 @@ class _$AppRouter extends RootStackRouter {
opaque: true,
barrierDismissible: false);
},
BackupAlbumSelectionRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const BackupAlbumSelectionPage());
},
AlbumPreviewRoute.name: (routeData) {
final args = routeData.argsAs<AlbumPreviewRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: AlbumPreviewPage(key: args.key, album: args.album));
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
@@ -149,7 +159,11 @@ class _$AppRouter extends RootStackRouter {
path: '/album-viewer-page', guards: [authGuard]),
RouteConfig(SelectAdditionalUserForSharingRoute.name,
path: '/select-additional-user-for-sharing-page',
guards: [authGuard])
guards: [authGuard]),
RouteConfig(BackupAlbumSelectionRoute.name,
path: '/backup-album-selection-page', guards: [authGuard]),
RouteConfig(AlbumPreviewRoute.name,
path: '/album-preview-page', guards: [authGuard])
];
}
@@ -358,6 +372,40 @@ class SelectAdditionalUserForSharingRouteArgs {
}
}
/// generated route for
/// [BackupAlbumSelectionPage]
class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
const BackupAlbumSelectionRoute()
: super(BackupAlbumSelectionRoute.name,
path: '/backup-album-selection-page');
static const String name = 'BackupAlbumSelectionRoute';
}
/// generated route for
/// [AlbumPreviewPage]
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
AlbumPreviewRoute({Key? key, required AssetPathEntity album})
: super(AlbumPreviewRoute.name,
path: '/album-preview-page',
args: AlbumPreviewRouteArgs(key: key, album: album));
static const String name = 'AlbumPreviewRoute';
}
class AlbumPreviewRouteArgs {
const AlbumPreviewRouteArgs({this.key, required this.album});
final Key? key;
final AssetPathEntity album;
@override
String toString() {
return 'AlbumPreviewRouteArgs{key: $key, album: $album}';
}
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@@ -1,77 +0,0 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart';
enum BackUpProgressEnum { idle, inProgress, done }
class BackUpState {
final BackUpProgressEnum backupProgress;
final int totalAssetCount;
final int assetOnDatabase;
final int backingUpAssetCount;
final double progressInPercentage;
final CancelToken cancelToken;
final ServerInfo serverInfo;
BackUpState({
required this.backupProgress,
required this.totalAssetCount,
required this.assetOnDatabase,
required this.backingUpAssetCount,
required this.progressInPercentage,
required this.cancelToken,
required this.serverInfo,
});
BackUpState copyWith({
BackUpProgressEnum? backupProgress,
int? totalAssetCount,
int? assetOnDatabase,
int? backingUpAssetCount,
double? progressInPercentage,
CancelToken? cancelToken,
ServerInfo? serverInfo,
}) {
return BackUpState(
backupProgress: backupProgress ?? this.backupProgress,
totalAssetCount: totalAssetCount ?? this.totalAssetCount,
assetOnDatabase: assetOnDatabase ?? this.assetOnDatabase,
backingUpAssetCount: backingUpAssetCount ?? this.backingUpAssetCount,
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
);
}
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, totalAssetCount: $totalAssetCount, assetOnDatabase: $assetOnDatabase, backingUpAssetCount: $backingUpAssetCount, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BackUpState &&
other.backupProgress == backupProgress &&
other.totalAssetCount == totalAssetCount &&
other.assetOnDatabase == assetOnDatabase &&
other.backingUpAssetCount == backingUpAssetCount &&
other.progressInPercentage == progressInPercentage &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo;
}
@override
int get hashCode {
return backupProgress.hashCode ^
totalAssetCount.hashCode ^
assetOnDatabase.hashCode ^
backingUpAssetCount.hashCode ^
progressInPercentage.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode;
}
}

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
class UploadProfileImageResponse {
final String userId;
final String profileImagePath;
UploadProfileImageResponse({
required this.userId,
required this.profileImagePath,
});
UploadProfileImageResponse copyWith({
String? userId,
String? profileImagePath,
}) {
return UploadProfileImageResponse(
userId: userId ?? this.userId,
profileImagePath: profileImagePath ?? this.profileImagePath,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'userId': userId});
result.addAll({'profileImagePath': profileImagePath});
return result;
}
factory UploadProfileImageResponse.fromMap(Map<String, dynamic> map) {
return UploadProfileImageResponse(
userId: map['userId'] ?? '',
profileImagePath: map['profileImagePath'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory UploadProfileImageResponse.fromJson(String source) => UploadProfileImageResponse.fromMap(json.decode(source));
@override
String toString() => 'UploadProfileImageReponse(userId: $userId, profileImagePath: $profileImagePath)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UploadProfileImageResponse && other.userId == userId && other.profileImagePath == profileImagePath;
}
@override
int get hashCode => userId.hashCode ^ profileImagePath.hashCode;
}

View File

@@ -1,194 +0,0 @@
import 'dart:async';
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/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart';
import 'package:immich_mobile/shared/services/backup.service.dart';
import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier({this.ref})
: super(
BackUpState(
backupProgress: BackUpProgressEnum.idle,
backingUpAssetCount: 0,
assetOnDatabase: 0,
totalAssetCount: 0,
progressInPercentage: 0,
cancelToken: CancelToken(),
serverInfo: ServerInfo(
diskAvailable: "0",
diskAvailableRaw: 0,
diskSize: "0",
diskSizeRaw: 0,
diskUsagePercentage: 0.0,
diskUse: "0",
diskUseRaw: 0,
),
),
);
Ref? ref;
final BackupService _backupService = BackupService();
final ServerInfoService _serverInfoService = ServerInfoService();
final StreamController _onAssetBackupStreamCtrl =
StreamController.broadcast();
void getBackupInfo() async {
_updateServerInfo();
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
onlyAll: true, type: RequestType.common);
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
if (list.isEmpty) {
debugPrint("No Asset On Device");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
totalAssetCount: 0,
assetOnDatabase: didBackupAsset.length);
return;
}
int totalAsset = list[0].assetCount;
state = state.copyWith(
totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
}
void startBackupProcess() async {
_updateServerInfo();
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
var authResult = await PhotoManager.requestPermissionExtend();
if (authResult.isAuth) {
await PhotoManager.clearFileCache();
// await PhotoManager.presentLimited();
// Gather assets info
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
hasAll: true, onlyAll: true, type: RequestType.common);
// Get device assets info from database
// Compare and find different assets that has not been backing up
// Backup those assets
List<String> backupAsset = await _backupService.getDeviceBackupAsset();
if (list.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
totalAssetCount: 0,
assetOnDatabase: backupAsset.length);
return;
}
int totalAsset = list[0].assetCount;
List<AssetEntity> currentAssets =
await list[0].getAssetListRange(start: 0, end: totalAsset);
state = state.copyWith(
totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
// Remove item that has already been backed up
for (var backupAssetId in backupAsset) {
currentAssets.removeWhere((e) => e.id == backupAssetId);
}
if (currentAssets.isEmpty) {
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
}
state = state.copyWith(backingUpAssetCount: currentAssets.length);
// Perform Backup
state = state.copyWith(cancelToken: CancelToken());
_backupService.backupAsset(currentAssets, state.cancelToken,
_onAssetUploaded, _onUploadProgress);
} else {
PhotoManager.openSetting();
}
}
void cancelBackup() {
state.cancelToken.cancel('Cancel Backup');
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state.copyWith(
backingUpAssetCount: state.backingUpAssetCount - 1,
assetOnDatabase: state.assetOnDatabase + 1);
if (state.backingUpAssetCount == 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));
}
void _updateServerInfo() async {
var serverInfo = await _serverInfoService.getServerInfo();
// Update server info
state = state.copyWith(
serverInfo: ServerInfo(
diskSize: serverInfo.diskSize,
diskUse: serverInfo.diskUse,
diskAvailable: serverInfo.diskAvailable,
diskSizeRaw: serverInfo.diskSizeRaw,
diskUseRaw: serverInfo.diskUseRaw,
diskAvailableRaw: serverInfo.diskAvailableRaw,
diskUsagePercentage: serverInfo.diskUsagePercentage,
),
);
}
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");
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;
}
}
}
final backupProvider =
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(ref: ref);
});

View File

@@ -2,8 +2,15 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/upload_profile_image_repsonse.model.dart';
import 'package:immich_mobile/shared/models/user_info.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:http_parser/http_parser.dart';
class UserService {
final NetworkService _networkService = NetworkService();
@@ -21,4 +28,39 @@ class UserService {
return [];
}
Future<UploadProfileImageResponse?> uploadProfileImage(XFile image) async {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var mimeType = FileHelper.getMimeType(image.path);
final imageData = MultipartFile.fromBytes(
await image.readAsBytes(),
filename: image.name,
contentType: MediaType(
mimeType["type"],
mimeType["subType"],
),
);
final formData = FormData.fromMap({'file': imageData});
try {
Response res = await dio.post(
'$savedEndpoint/user/profile-image',
data: formData,
);
var payload = UploadProfileImageResponse.fromJson(res.toString());
return payload;
} on DioError catch (e) {
debugPrint("Error uploading file: ${e.response}");
return null;
} catch (e) {
debugPrint("Error uploading file: $e");
return null;
}
}
}

View File

@@ -7,28 +7,28 @@ packages:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "34.0.0"
version: "38.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
version: "3.4.1"
archive:
dependency: transitive
description:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.11"
version: "3.3.0"
args:
dependency: transitive
description:
name: args
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.0"
version: "2.3.1"
async:
dependency: transitive
description:
@@ -42,14 +42,14 @@ packages:
name: auto_route
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.2"
version: "4.0.1"
auto_route_generator:
dependency: "direct dev"
description:
name: auto_route_generator
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.1"
version: "4.0.0"
badges:
dependency: "direct main"
description:
@@ -70,7 +70,7 @@ packages:
name: build
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
version: "2.3.0"
build_config:
dependency: transitive
description:
@@ -84,21 +84,21 @@ packages:
name: build_daemon
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "3.1.0"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
version: "2.0.8"
build_runner:
dependency: "direct dev"
description:
name: build_runner
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.7"
version: "2.1.10"
build_runner_core:
dependency: transitive
description:
@@ -119,14 +119,14 @@ packages:
name: built_value
url: "https://pub.dartlang.org"
source: hosted
version: "8.1.4"
version: "8.3.0"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
version: "3.2.1"
cached_network_image_platform_interface:
dependency: transitive
description:
@@ -168,14 +168,7 @@ packages:
name: chewie
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.2"
cli_util:
dependency: transitive
description:
name: cli_util
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.5"
version: "1.3.2"
clock:
dependency: transitive
description:
@@ -196,7 +189,7 @@ packages:
name: collection
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.0"
version: "1.16.0"
convert:
dependency: transitive
description:
@@ -204,13 +197,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
cross_file:
dependency: transitive
description:
name: cross_file
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3+1"
crypto:
dependency: transitive
description:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "3.0.2"
csslib:
dependency: transitive
description:
@@ -231,14 +231,21 @@ packages:
name: dart_style
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
version: "2.2.3"
dio:
dependency: "direct main"
description:
name: dio
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.4"
version: "4.0.6"
equatable:
dependency: "direct main"
description:
name: equatable
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
exif:
dependency: "direct main"
description:
@@ -252,14 +259,14 @@ packages:
name: fake_async
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
version: "1.3.0"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.2"
version: "1.2.1"
file:
dependency: transitive
description:
@@ -273,7 +280,7 @@ packages:
name: fixnum
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
version: "1.0.1"
flutter:
dependency: "direct main"
description: flutter
@@ -285,7 +292,7 @@ packages:
name: flutter_blurhash
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.0"
version: "0.6.8"
flutter_cache_manager:
dependency: transitive
description:
@@ -299,7 +306,7 @@ packages:
name: flutter_hooks
url: "https://pub.dartlang.org"
source: hosted
version: "0.18.2"
version: "0.18.4"
flutter_launcher_icons:
dependency: "direct main"
description:
@@ -321,13 +328,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
flutter_riverpod:
dependency: transitive
description:
name: flutter_riverpod
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0-dev.0"
version: "2.0.0-dev.7"
flutter_spinkit:
dependency: "direct main"
description:
@@ -365,7 +379,7 @@ packages:
name: fluttertoast
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.8"
version: "8.0.9"
frontend_server_client:
dependency: transitive
description:
@@ -393,7 +407,7 @@ packages:
name: hive
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
version: "2.2.1"
hive_flutter:
dependency: "direct main"
description:
@@ -414,7 +428,7 @@ packages:
name: hooks_riverpod
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0-dev.0"
version: "2.0.0-dev.7"
html:
dependency: transitive
description:
@@ -435,7 +449,7 @@ packages:
name: http_multi_server
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "3.2.0"
http_parser:
dependency: transitive
description:
@@ -449,7 +463,42 @@ packages:
name: image
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
version: "3.1.3"
image_picker:
dependency: "direct main"
description:
name: image_picker
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.5+3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.4+13"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.8"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.5+5"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.5.0"
intl:
dependency: "direct main"
description:
@@ -470,14 +519,14 @@ packages:
name: js
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3"
version: "0.6.4"
json_annotation:
dependency: transitive
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.4.0"
version: "4.5.0"
latlong2:
dependency: transitive
description:
@@ -519,7 +568,7 @@ packages:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
version: "0.1.4"
meta:
dependency: transitive
description:
@@ -540,7 +589,7 @@ packages:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
nested:
dependency: transitive
description:
@@ -568,14 +617,14 @@ packages:
name: package_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
version: "1.4.2"
package_info_plus_linux:
dependency: transitive
description:
name: package_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
version: "1.0.5"
package_info_plus_macos:
dependency: transitive
description:
@@ -596,70 +645,70 @@ packages:
name: package_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
version: "1.0.5"
package_info_plus_windows:
dependency: transitive
description:
name: package_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
version: "1.0.5"
path:
dependency: transitive
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
version: "1.8.1"
path_provider:
dependency: transitive
description:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
version: "2.0.10"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.11"
version: "2.0.14"
path_provider_ios:
dependency: transitive
description:
name: path_provider_ios
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
version: "2.0.9"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.5"
version: "2.1.6"
path_provider_macos:
dependency: transitive
description:
name: path_provider_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
version: "2.0.6"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
version: "2.0.4"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
version: "2.0.6"
pedantic:
dependency: transitive
description:
@@ -673,21 +722,21 @@ packages:
name: percent_indicator
url: "https://pub.dartlang.org"
source: hosted
version: "3.4.0"
version: "4.2.2"
petitparser:
dependency: transitive
description:
name: petitparser
url: "https://pub.dartlang.org"
source: hosted
version: "4.4.0"
version: "5.0.0"
photo_manager:
dependency: "direct main"
description:
name: photo_manager
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
version: "2.1.0+2"
photo_view:
dependency: "direct main"
description:
@@ -743,14 +792,14 @@ packages:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
version: "6.0.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
version: "2.1.1"
pubspec_parse:
dependency: transitive
description:
@@ -764,14 +813,14 @@ packages:
name: quiver
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1+1"
version: "3.1.0"
riverpod:
dependency: transitive
description:
name: riverpod
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0-dev.0"
version: "2.0.0-dev.7"
rxdart:
dependency: transitive
description:
@@ -785,7 +834,7 @@ packages:
name: shelf
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
version: "1.3.0"
shelf_web_socket:
dependency: transitive
description:
@@ -804,7 +853,7 @@ packages:
name: sliver_tools
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.5"
version: "0.2.6"
socket_io_client:
dependency: "direct main"
description:
@@ -825,21 +874,21 @@ packages:
name: source_gen
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
version: "1.2.2"
source_helper:
dependency: transitive
description:
name: source_helper
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
version: "1.3.2"
source_span:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
version: "1.8.2"
sprintf:
dependency: transitive
description:
@@ -853,14 +902,14 @@ packages:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
version: "2.0.2+1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
version: "2.2.1+1"
stack_trace:
dependency: transitive
description:
@@ -902,7 +951,7 @@ packages:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "3.0.0+2"
term_glyph:
dependency: transitive
description:
@@ -916,7 +965,7 @@ packages:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.8"
version: "0.4.9"
timing:
dependency: transitive
description:
@@ -944,7 +993,7 @@ packages:
name: typed_data
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
version: "1.3.1"
unicode:
dependency: transitive
description:
@@ -972,49 +1021,56 @@ packages:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.5"
version: "3.0.6"
vector_math:
dependency: transitive
description:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "2.1.2"
very_good_analysis:
dependency: transitive
description:
name: very_good_analysis
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
video_player:
dependency: "direct main"
description:
name: video_player
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.18"
version: "2.4.2"
video_player_android:
dependency: transitive
description:
name: video_player_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.17"
version: "2.3.3"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.18"
version: "2.3.4"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.1"
version: "5.1.2"
video_player_web:
dependency: transitive
description:
name: video_player_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
version: "2.0.10"
visibility_detector:
dependency: "direct main"
description:
@@ -1028,7 +1084,7 @@ packages:
name: wakelock
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.6"
version: "0.6.1+2"
wakelock_macos:
dependency: transitive
description:
@@ -1070,14 +1126,14 @@ packages:
name: web_socket_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
version: "2.2.0"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.8"
version: "2.5.2"
wkt_parser:
dependency: transitive
description:
@@ -1091,21 +1147,21 @@ packages:
name: xdg_directories
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
version: "0.2.0+1"
xml:
dependency: transitive
description:
name: xml
url: "https://pub.dartlang.org"
source: hosted
version: "5.3.1"
version: "5.4.1"
yaml:
dependency: transitive
description:
name: yaml
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
version: "3.1.1"
sdks:
dart: ">=2.15.1 <3.0.0"
flutter: ">=2.8.0"
dart: ">=2.17.0 <3.0.0"
flutter: ">=3.0.0"

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.8.0+12
version: 1.10.0+15
environment:
sdk: ">=2.15.1 <3.0.0"
@@ -14,13 +14,13 @@ dependencies:
photo_manager: ^2.0.6
flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0
hive:
hive_flutter:
hive: ^2.2.1
hive_flutter: ^1.1.0
dio: ^4.0.4
cached_network_image: ^3.2.0
percent_indicator: ^3.4.0
cached_network_image: ^3.2.1
percent_indicator: ^4.2.2
intl: ^0.17.0
auto_route: ^3.2.2
auto_route: ^4.0.1
exif: ^3.1.1
transparent_image: ^2.0.0
visibility_detector: ^0.2.2
@@ -37,6 +37,8 @@ dependencies:
package_info_plus: ^1.4.0
flutter_spinkit: ^5.1.0
flutter_swipe_detector: ^2.0.0
equatable: ^2.0.3
image_picker: ^0.8.5+3
dev_dependencies:
flutter_test:
@@ -44,7 +46,7 @@ dev_dependencies:
flutter_lints: ^1.0.0
hive_generator: ^1.1.2
build_runner: ^2.1.7
auto_route_generator: ^3.2.1
auto_route_generator: ^4.0.0
flutter:
uses-material-design: true

View File

@@ -1,5 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120
"printWidth": 120,
"semi": true
}

View File

@@ -6,7 +6,7 @@ WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN apk add --update-cache build-base python3
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
RUN npm install

637
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
"@nestjs/platform-express": "^8.0.0",
"@nestjs/platform-fastify": "^8.2.6",
"@nestjs/platform-socket.io": "^8.2.6",
"@nestjs/schedule": "^2.0.1",
"@nestjs/typeorm": "^8.0.3",
"@nestjs/websockets": "^8.2.6",
"@socket.io/redis-adapter": "^7.1.0",
@@ -44,6 +45,7 @@
"diskusage": "^1.1.3",
"dotenv": "^14.2.0",
"exifr": "^7.1.3",
"fluent-ffmpeg": "^2.1.2",
"joi": "^17.5.0",
"lodash": "^4.17.21",
"passport": "^0.5.2",
@@ -53,6 +55,7 @@
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"sharp": "^0.28.0",
"socket.io-redis": "^6.1.1",
"systeminformation": "^5.11.0",
"typeorm": "^0.2.41"
@@ -63,6 +66,7 @@
"@nestjs/testing": "^8.0.0",
"@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.13",
"@types/imagemin": "^8.0.0",
"@types/jest": "27.0.2",
@@ -70,6 +74,7 @@
"@types/multer": "^1.4.7",
"@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.30.2",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",

View File

@@ -19,7 +19,7 @@ import {
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { multerOption } from '../../config/multer-option.config';
import { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto';
@@ -39,7 +39,7 @@ export class AssetController {
private wsCommunicateionGateway: CommunicationGateway,
private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService,
) {}
) { }
@Post('upload')
@UseInterceptors(
@@ -48,7 +48,7 @@ export class AssetController {
{ name: 'assetData', maxCount: 1 },
{ name: 'thumbnailData', maxCount: 1 },
],
multerOption,
assetUploadOption,
),
)
async uploadFile(

View File

@@ -1,19 +1,16 @@
import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetEntity, AssetType } from './entities/asset.entity';
import _ from 'lodash';
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
import { createReadStream, stat } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import path from 'path';
const fileInfo = promisify(stat);
@@ -22,7 +19,7 @@ export class AssetService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
) { }
public async updateThumbnailInfo(assetId: string, path: string) {
return await this.assetRepository.update(assetId, {
@@ -114,34 +111,66 @@ export class AssetService {
public async getAssetThumbnail(assetId: string) {
const asset = await this.assetRepository.findOne({ id: assetId });
return new StreamableFile(createReadStream(asset.resizePath));
if (asset.webpPath != '') {
return new StreamableFile(createReadStream(asset.webpPath));
} else {
return new StreamableFile(createReadStream(asset.resizePath));
}
}
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
let file = null;
const asset = await this.findOne(query.did, query.aid);
if (!asset) {
throw new BadRequestException('Asset does not exist');
}
// Handle Sending Images
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
res.set({
'Content-Type': asset.mimeType,
});
/**
* Serve file viewer on the web
*/
if (query.isWeb) {
res.set({
'Content-Type': 'image/jpeg',
});
return new StreamableFile(createReadStream(asset.resizePath));
}
/**
* Serve thumbnail image for both web and mobile app
*/
if (query.isThumb === 'false' || !query.isThumb) {
res.set({
'Content-Type': asset.mimeType,
});
file = createReadStream(asset.originalPath);
} else {
file = createReadStream(asset.resizePath);
if (asset.webpPath != '') {
res.set({
'Content-Type': 'image/webp',
});
file = createReadStream(asset.webpPath);
} else {
res.set({
'Content-Type': 'image/jpeg',
});
file = createReadStream(asset.resizePath);
}
}
file.on('error', (error) => {
Logger.log(`Cannot create read stream ${error}`);
return new BadRequestException('Cannot Create Read Stream');
});
return new StreamableFile(file);
} else if (asset.type == AssetType.VIDEO) {
// Handle Handling Video
// Handle Video
const { size } = await fileInfo(asset.originalPath);
const range = headers.range;
@@ -183,6 +212,8 @@ export class AssetService {
const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
return new StreamableFile(videoStream);
} else {
res.set({
'Content-Type': asset.mimeType,

View File

@@ -13,4 +13,8 @@ export class ServeFileDto {
@IsOptional()
@IsBooleanString()
isThumb: string;
@IsOptional()
@IsBooleanString()
isWeb: string;
}

View File

@@ -26,6 +26,9 @@ export class AssetEntity {
@Column({ nullable: true })
resizePath: string;
@Column({ nullable: true })
webpPath: string;
@Column()
createdAt: string;

View File

@@ -7,16 +7,16 @@ import { SignUpDto } from './dto/sign-up.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(private readonly authService: AuthService) { }
@Post('/login')
async login(@Body(ValidationPipe) loginCredential: LoginCredentialDto) {
return await this.authService.login(loginCredential);
}
@Post('/signUp')
async signUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) {
return await this.authService.signUp(signUpCrendential);
@Post('/admin-sign-up')
async adminSignUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) {
return await this.authService.adminSignUp(signUpCrendential);
}
@UseGuards(JwtAuthGuard)

View File

@@ -14,12 +14,12 @@ export class AuthService {
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
private immichJwtService: ImmichJwtService,
) {}
) { }
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> {
const user = await this.userRepository.findOne(
{ email: loginCredential.email },
{ select: ['id', 'email', 'password', 'salt'] },
{ select: ['id', 'email', 'password', 'salt', 'firstName', 'lastName', 'isAdmin', 'profileImagePath', 'isFirstLoggedIn'] },
);
const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt);
@@ -44,32 +44,45 @@ export class AuthService {
accessToken: await this.immichJwtService.generateToken(payload),
userId: validatedUser.id,
userEmail: validatedUser.email,
firstName: validatedUser.firstName,
lastName: validatedUser.lastName,
isAdmin: validatedUser.isAdmin,
profileImagePath: validatedUser.profileImagePath,
isFirstLogin: validatedUser.isFirstLoggedIn
};
}
public async signUp(signUpCrendential: SignUpDto) {
const registerUser = await this.userRepository.findOne({ email: signUpCrendential.email });
if (registerUser) {
throw new BadRequestException('User exist');
public async adminSignUp(signUpCrendential: SignUpDto) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) {
throw new BadRequestException('The server already has an admin')
}
const newUser = new UserEntity();
newUser.email = signUpCrendential.email;
newUser.salt = await bcrypt.genSalt();
newUser.password = await this.hashPassword(signUpCrendential.password, newUser.salt);
const newAdminUser = new UserEntity();
newAdminUser.email = signUpCrendential.email;
newAdminUser.salt = await bcrypt.genSalt();
newAdminUser.password = await this.hashPassword(signUpCrendential.password, newAdminUser.salt);
newAdminUser.firstName = signUpCrendential.firstName;
newAdminUser.lastName = signUpCrendential.lastName;
newAdminUser.isAdmin = true;
try {
const savedUser = await this.userRepository.save(newUser);
const savedNewAdminUserUser = await this.userRepository.save(newAdminUser);
return {
id: savedUser.id,
email: savedUser.email,
createdAt: savedUser.createdAt,
id: savedNewAdminUserUser.id,
email: savedNewAdminUserUser.email,
firstName: savedNewAdminUserUser.firstName,
lastName: savedNewAdminUserUser.lastName,
createdAt: savedNewAdminUserUser.createdAt,
};
} catch (e) {
Logger.error('e', 'signUp');
throw new InternalServerErrorException('Failed to register new user');
throw new InternalServerErrorException('Failed to register new admin user');
}
}

View File

@@ -6,4 +6,10 @@ export class SignUpDto {
@IsNotEmpty()
password: string;
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
}

View File

@@ -1 +1,27 @@
export class CreateUserDto {}
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsNotEmpty()
email: string;
@IsNotEmpty()
password: string;
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
@IsOptional()
profileImagePath: string;
@IsOptional()
isAdmin: boolean;
@IsOptional()
isFirstLoggedIn: boolean;
@IsOptional()
id: string;
}

View File

@@ -5,6 +5,15 @@ export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
isAdmin: boolean;
@Column()
email: string;
@@ -14,6 +23,12 @@ export class UserEntity {
@Column({ select: false })
salt: string;
@Column()
profileImagePath: string;
@Column()
isFirstLoggedIn: boolean;
@CreateDateColumn()
createdAt: string;
}

View File

@@ -1,15 +1,54 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Put, Query, UseInterceptors, UploadedFile, Response } from '@nestjs/common';
import { UserService } from './user.service';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
import { UpdateUserDto } from './dto/update-user.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { profileImageUploadOption } from '../../config/profile-image-upload.config';
import { Response as Res } from 'express';
@UseGuards(JwtAuthGuard)
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
constructor(private readonly userService: UserService) { }
@UseGuards(JwtAuthGuard)
@Get()
async getAllUsers(@GetAuthUser() authUser: AuthUserDto) {
return await this.userService.getAllUsers(authUser);
async getAllUsers(@GetAuthUser() authUser: AuthUserDto, @Query('isAll') isAll: boolean) {
return await this.userService.getAllUsers(authUser, isAll);
}
@UseGuards(JwtAuthGuard)
@UseGuards(AdminRolesGuard)
@Post()
async createNewUser(@Body(ValidationPipe) createUserDto: CreateUserDto) {
return await this.userService.createUser(createUserDto);
}
@Get('/count')
async getUserCount(@Query('isAdmin') isAdmin: boolean) {
return await this.userService.getUserCount(isAdmin);
}
@UseGuards(JwtAuthGuard)
@Put()
async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto) {
return await this.userService.updateUser(updateUserDto)
}
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file', profileImageUploadOption))
@Post('/profile-image')
async createProfileImage(@GetAuthUser() authUser: AuthUserDto, @UploadedFile() fileInfo: Express.Multer.File) {
return await this.userService.createProfileImage(authUser, fileInfo);
}
@Get('/profile-image/:userId')
async getProfileImage(@Param('userId') userId: string,
@Response({ passthrough: true }) res: Res,
) {
return await this.userService.getUserProfileImage(userId, res);
}
}

View File

@@ -3,10 +3,14 @@ import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
controllers: [UserController],
providers: [UserService],
providers: [UserService, ImmichJwtService],
})
export class UserModule {}
export class UserModule { }

View File

@@ -1,21 +1,166 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserEntity } from './entities/user.entity';
import * as bcrypt from 'bcrypt';
import sharp from 'sharp';
import { createReadStream, unlink, unlinkSync } from 'fs';
import { Response as Res } from 'express';
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
) { }
async getAllUsers(authUser: AuthUserDto, isAll: boolean) {
if (isAll) {
return await this.userRepository.find();
}
async getAllUsers(authUser: AuthUserDto) {
return await this.userRepository.find({
where: { id: Not(authUser.id) },
order: {
createdAt: 'DESC'
}
});
}
async getUserCount(isAdmin: boolean) {
let users;
if (isAdmin) {
users = await this.userRepository.find({ where: { isAdmin: true } });
} else {
users = await this.userRepository.find();
}
return {
userCount: users.length
}
}
async createUser(createUserDto: CreateUserDto) {
const user = await this.userRepository.findOne({ where: { email: createUserDto.email } });
if (user) {
throw new BadRequestException('User exists');
}
const newUser = new UserEntity();
newUser.email = createUserDto.email;
newUser.salt = await bcrypt.genSalt();
newUser.password = await this.hashPassword(createUserDto.password, newUser.salt);
newUser.firstName = createUserDto.firstName;
newUser.lastName = createUserDto.lastName;
newUser.isAdmin = false;
try {
const savedUser = await this.userRepository.save(newUser);
return {
id: savedUser.id,
email: savedUser.email,
firstName: savedUser.firstName,
lastName: savedUser.lastName,
createdAt: savedUser.createdAt,
};
} catch (e) {
Logger.error(e, 'Create new user');
throw new InternalServerErrorException('Failed to register new user');
}
}
private async hashPassword(password: string, salt: string): Promise<string> {
return bcrypt.hash(password, salt);
}
async updateUser(updateUserDto: UpdateUserDto) {
const user = await this.userRepository.findOne(updateUserDto.id);
user.lastName = updateUserDto.lastName || user.lastName;
user.firstName = updateUserDto.firstName || user.firstName;
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
user.isFirstLoggedIn = updateUserDto.isFirstLoggedIn || user.isFirstLoggedIn;
// If payload includes password - Create new password for user
if (updateUserDto.password) {
user.salt = await bcrypt.genSalt();
user.password = await this.hashPassword(updateUserDto.password, user.salt);
}
if (updateUserDto.isAdmin) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } })
if (adminUser) {
throw new BadRequestException("Admin user exists")
}
user.isAdmin = true;
}
try {
const updatedUser = await this.userRepository.save(user);
return {
id: updatedUser.id,
email: updatedUser.email,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
isAdmin: updatedUser.isAdmin,
profileImagePath: updatedUser.profileImagePath,
};
} catch (e) {
Logger.error(e, 'Create new user');
throw new InternalServerErrorException('Failed to register new user');
}
}
async createProfileImage(authUser: AuthUserDto, fileInfo: Express.Multer.File) {
try {
await this.userRepository.update(authUser.id, {
profileImagePath: fileInfo.path
})
return {
userId: authUser.id,
profileImagePath: fileInfo.path
};
} catch (e) {
Logger.error(e, 'Create User Profile Image');
throw new InternalServerErrorException('Failed to create new user profile image');
}
}
async getUserProfileImage(userId: string, res: Res) {
try {
const user = await this.userRepository.findOne({ id: userId })
if (!user.profileImagePath) {
console.log("empty return")
throw new BadRequestException('User does not have a profile image');
}
res.set({
'Content-Type': 'image/jpeg',
});
const fileStream = createReadStream(user.profileImagePath)
return new StreamableFile(fileStream);
} catch (e) {
console.log("error getting user profile")
}
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Res, Headers } from '@nestjs/common';
import { Response } from 'express';
@Controller()
export class AppController {
constructor() { }
@Get()
async redirectToWebpage(@Res({ passthrough: true }) res: Response, @Headers() headers) {
const host = headers.host;
return res.redirect(`http://${host}:2285`)
}
}

View File

@@ -15,20 +15,31 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
import { CommunicationModule } from './api-v1/communication/communication.module';
import { SharingModule } from './api-v1/sharing/sharing.module';
import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
@Module({
imports: [
ConfigModule.forRoot(immichAppConfig),
TypeOrmModule.forRoot(databaseConfig),
UserModule,
AssetModule,
AuthModule,
ImmichJwtModule,
DeviceInfoModule,
BullModule.forRootAsync({
useFactory: async () => ({
redis: {
host: 'immich_redis',
host: process.env.REDIS_HOSTNAME || 'immich_redis',
port: 6379,
},
}),
@@ -43,8 +54,12 @@ import { SharingModule } from './api-v1/sharing/sharing.module';
CommunicationModule,
SharingModule,
ScheduleModule.forRoot(),
ScheduleTasksModule
],
controllers: [],
controllers: [AppController],
providers: [],
})
export class AppModule implements NestModule {

View File

@@ -17,5 +17,6 @@ export const immichAppConfig: ConfigModuleOptions = {
then: Joi.string().optional().allow(null, ''),
otherwise: Joi.string().required(),
}),
VITE_SERVER_ENDPOINT: Joi.string().required(),
}),
};

View File

@@ -8,7 +8,7 @@ import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
import { randomUUID } from 'crypto';
import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto';
export const multerOption: MulterOptions = {
export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|webp)$/)) {
cb(null, true);
@@ -19,14 +19,14 @@ export const multerOption: MulterOptions = {
storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => {
const uploadPath = APP_UPLOAD_LOCATION;
const basePath = APP_UPLOAD_LOCATION;
const fileInfo = req.body as CreateAssetDto;
const yearInfo = new Date(fileInfo.createdAt).getFullYear();
const monthInfo = new Date(fileInfo.createdAt).getMonth();
if (file.fieldname == 'assetData') {
const originalUploadFolder = `${uploadPath}/${req.user['id']}/original/${req.body['deviceId']}`;
const originalUploadFolder = `${basePath}/${req.user['id']}/original/${req.body['deviceId']}`;
if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true });
@@ -35,7 +35,7 @@ export const multerOption: MulterOptions = {
// Save original to disk
cb(null, originalUploadFolder);
} else if (file.fieldname == 'thumbnailData') {
const thumbnailUploadFolder = `${uploadPath}/${req.user['id']}/thumb/${req.body['deviceId']}`;
const thumbnailUploadFolder = `${basePath}/${req.user['id']}/thumb/${req.body['deviceId']}`;
if (!existsSync(thumbnailUploadFolder)) {
mkdirSync(thumbnailUploadFolder, { recursive: true });
@@ -56,3 +56,5 @@ export const multerOption: MulterOptions = {
},
}),
};

View File

@@ -2,7 +2,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
host: 'immich_postgres',
host: process.env.DB_HOSTNAME || 'immich_postgres',
port: 5432,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,

View File

@@ -0,0 +1,40 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
export const profileImageUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|heic|heif|dng|webp)$/)) {
cb(null, true);
} else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
}
},
storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => {
const basePath = APP_UPLOAD_LOCATION;
const profileImageLocation = `${basePath}/${req.user['id']}/profile`;
if (!existsSync(profileImageLocation)) {
mkdirSync(profileImageLocation, { recursive: true });
}
cb(null, profileImageLocation);
},
filename: (req: Request, file: Express.Multer.File, cb: any) => {
const userId = req.user['id'];
cb(null, `${userId}${extname(file.originalname)}`);
},
}),
};

View File

@@ -3,7 +3,7 @@
export const serverVersion = {
major: 1,
minor: 8,
minor: 10,
patch: 0,
build: 12,
build: 14,
};

View File

@@ -1,4 +1,4 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from '../api-v1/user/entities/user.entity';
// import { AuthUserDto } from './dto/auth-user.dto';
@@ -18,4 +18,4 @@ export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): A
};
return authUser;
});
});

View File

@@ -7,6 +7,8 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors();
app.set('trust proxy');
app.useWebSocketAdapter(new RedisIoAdapter(app));

View File

@@ -0,0 +1,30 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../api-v1/user/entities/user.entity';
import { ImmichJwtService } from '../modules/immich-jwt/immich-jwt.service';
@Injectable()
export class AdminRolesGuard implements CanActivate {
constructor(private reflector: Reflector, private jwtService: ImmichJwtService,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
if (request.headers['authorization']) {
const bearerToken = request.headers['authorization'].split(" ")[1]
const { userId } = await this.jwtService.validateToken(bearerToken);
const user = await this.userRepository.findOne(userId);
return user.isAdmin;
}
return false;
}
}

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