Compare commits

..

49 Commits

Author SHA1 Message Date
Alex
a8220172f8 WIP refactor container and queuing system (#206)
* refactor microservices to machine-learning

* Update tGithub issue template with correct task syntax

* Added microservices container

* Communicate between service based on queue system

* added dependency

* Fixed problem with having to import BullQueue into the individual service

* Added todo

* refactor server into monorepo with microservices

* refactor database and entity to library

* added simple migration

* Move migrations and database config to library

* Migration works in library

* Cosmetic change in logging message

* added user dto

* Fixed issue with testing not able to find the shared library

* Clean up library mapping path

* Added webp generator to microservices

* Update Github Action build latest

* Fixed issue NPM cannot install due to conflict witl Bull Queue

* format project with prettier

* Modified docker-compose file

* Add GH Action for Staging build:

* Fixed GH action job name

* Modified GH Action to only build & push latest when pushing to main

* Added Test 2e2 Github Action

* Added Test 2e2 Github Action

* Implemented microservice to extract exif

* Added cronjob to scan and generate webp thumbnail  at midnight

* Refactor to ireduce hit time to database when running microservices

* Added error handling to asset services that handle read file from disk

* Added video transcoding queue to process one video at a time

* Fixed loading spinner on web while loading covering the info panel

* Add mechanism to show new release announcement to web and mobile app (#209)

* Added changelog page

* Fixed issues based on PR comments

* Fixed issue with video transcoding run on the server

* Change entry point content for backward combatibility when starting up server

* Added announcement box

* Added error handling to failed silently when the app version checking is not able to make the request to GITHUB

* Added new version announcement overlay

* Update message

* Added messages

* Added logic to check and show announcement

* Add method to handle saving new version

* Added button to dimiss the acknowledge message

* Up version for deployment to the app store
2022-06-11 16:12:06 -05:00
Alex
397f8c70b4 Fixed NPM build dependency conflict for server 2022-06-07 08:08:22 -05:00
Matthias Rupp
68ff5377b0 Minor improvements to the detail-panel component (#205)
* Fix roudning behavior in details panel

* Add lat,lon-popup to map in details

* Refactor map code in detail-panel to avoid duplicate code
2022-06-06 16:40:12 -05:00
Jaime Baez
b359dc3cb6 Fix user e2e tests (#194)
* WIP fix user e2e tests

The e2e tests still don't seem to work due to migrations not running.

Changes:
- update user.e2e tests to use new `userService.createUser` method
- fix server `typeorm` command to use ORM config
- update make test-e2e to re-create database volume every time
- add User DTO
- update auth.service and user.service to use User DTO
- update CreateUserDto making optional properties that are optional

* Fix migrations
- add missing `.ts` extension to migrations path
- update user e2e test for the new returned User resource
2022-06-06 11:16:03 -05:00
Zack Pollard
5b036067ed Fix sidebar layout (#204)
* fix: sidebar margins with more than one item incorrect

* fix: api url in sidebar shouldn't overflow the sidebar width
2022-06-05 21:12:12 -05:00
Alex
b9f38162d5 Implemented status box on the side bar (#201) 2022-06-05 05:15:39 -05:00
Alex
ab6909bfbd 20 video conversion for web view (#200)
* Added job for video conversion every 1 minute

* Handle get video as mp4 on the web

* Auto play video on web on hovered

* Added video player

* Added animation and video duration to thumbnail player

* Fixed issue with video not playing on hover

* Added animation when loading thumbnail
2022-06-04 18:34:11 -05:00
Alex
53c3c916a6 View assets detail and download operation (#198)
* Fixed not displaying default user profile picture

* Added buttons to close viewer and micro-interaction for navigating assets left, right

* Add additional buttons to the control bar

* Display EXIF info

* Added map to detail info

* Handle user input keyboard

* Fixed incorrect file name when downloading multiple files

* Implemented download panel
2022-06-03 11:04:30 -05:00
Alex
6924aa5eb1 Update font size of 'create shared album' button to keep the text on one line 2022-05-29 18:15:16 -05:00
Alex
a3b45d62b6 175 Fixed issue back button android return to login page (#193)
* Back button is no longer return to login page

* Update to material 3

* Update to material 3

* Up version for deployment

* Added F-droid changelog
2022-05-29 17:32:30 -05:00
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
317 changed files with 14358 additions and 2683 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

@@ -4,61 +4,90 @@ on:
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build_and_push_server_latest:
# This image include both the server and microservices - the two containers can be slitted into separated
# service with its coressponding entry file.
build_and_push_server_monorepo_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main" # branch
fetch-depth: 0
- name: Set up QEMU
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
- name: Build and push Immich Mono Repo
uses: docker/build-push-action@v3.0.0
with:
context: ./server
file: ./server/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
push: true
tags: |
altran1502/immich-server:latest
build_and_push_microservice_latest:
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
build_and_push_machine_learning_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.0.0
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
platforms: linux/arm/v7,linux/amd64
push: true
tags: |
altran1502/immich-machine-learning:latest
build_and_push_web_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push 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: true
tags: |
altran1502/immich-web:latest

View File

@@ -0,0 +1,95 @@
name: Build and Push Docker Image - Staging
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# This image include both the server and microservices - the two containers can be slitted into separated
# service with its coressponding entry file.
build_and_push_server_monorepo_staging:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich Mono Repo
uses: docker/build-push-action@v3.0.0
with:
context: ./server
file: ./server/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name == 'pull_request' }}
tags: |
altran1502/immich-server:staging
build_and_push_machine_learning_staging:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.0.0
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
platforms: linux/arm/v7,linux/amd64
push: ${{ github.event_name == 'pull_request' }}
tags: |
altran1502/immich-machine-learning:staging
build_and_push_web_staging:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.0.0
with:
context: ./web
file: ./web/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
target: prod
push: ${{ github.event_name == 'pull_request' }}
tags: |
altran1502/immich-web:staging

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 --renew-anon-volumes --abort-on-container-exit --exit-code-from immich_server_test --remove-orphans
prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans

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=

22
docker/.env.test Normal file
View File

@@ -0,0 +1,22 @@
# Database
DB_HOSTNAME=immich_postgres_test
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=e2e_test
# Redis
REDIS_HOSTNAME=immich_redis_test
# Upload File Config
UPLOAD_LOCATION=./upload
# JWT SECRET
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
# WEB
MAPBOX_KEY=
VITE_SERVER_ENDPOINT=http://localhost:2283

View File

@@ -1,12 +1,12 @@
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
command: npm run start:dev
command: npm run start:dev immich
expose:
- "3000"
volumes:
@@ -21,18 +21,18 @@ services:
- redis
- database
networks:
- immich_network
- immich-network
immich_microservices:
image: immich-microservices-dev:1.8.0
immich-machine-learning:
image: immich-machine-learning-dev:1.9.0
build:
context: ../microservices
context: ../machine-learning
dockerfile: Dockerfile
command: npm run start:dev
expose:
- "3001"
volumes:
- ../microservices:/usr/src/app
- ../machine-learning:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
@@ -42,14 +42,51 @@ services:
depends_on:
- database
networks:
- immich_network
- immich-network
immich-microservices:
image: immich-microservices:1.9.0
build:
context: ../server
dockerfile: Dockerfile
command: npm run start:dev microservices
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
- .env
environment:
- NODE_ENV=development
depends_on:
- database
networks:
- 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 +103,7 @@ services:
ports:
- 5432:5432
networks:
- immich_network
- immich-network
nginx:
container_name: proxy_nginx
@@ -79,11 +116,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,110 @@
version: "3.8"
services:
immich-server:
image: altran1502/immich-server:staging
entrypoint: ["/bin/sh", "./start-server.sh"]
expose:
- "3000"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-microservices:
image: altran1502/immich-server:staging
entrypoint: ["/bin/sh", "./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-machine-learning:
image: altran1502/immich-machine-learning:staging
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- database
networks:
- immich-network
restart: always
immich-web:
image: altran1502/immich-web:staging
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
ports:
- 2285:3000
networks:
- immich-network
restart: always
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich-network
restart: always
database:
container_name: immich_postgres
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
restart: always
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,50 @@
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:
- /var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich_network_test
networks:
immich_network_test:

View File

@@ -1,9 +1,9 @@
version: "3.8"
services:
immich_server:
image: altran1502/immich-server:v1.8.0_12-dev
entrypoint: ["/bin/sh", "./entrypoint.sh"]
immich-server:
image: altran1502/immich-server:latest
entrypoint: ["/bin/sh", "./start-server.sh"]
expose:
- "3000"
volumes:
@@ -16,11 +16,27 @@ 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-server:latest
entrypoint: ["/bin/sh", "./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-machine-learning:
image: altran1502/immich-machine-learning:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
@@ -33,14 +49,26 @@ 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,7 +85,8 @@ services:
ports:
- 5432:5432
networks:
- immich_network
- immich-network
restart: always
nginx:
container_name: proxy_nginx
@@ -70,11 +99,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:
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

@@ -32,4 +32,6 @@ lerna-debug.log*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/extensions.json
upload/

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,9 +5,9 @@ import { ImageClassifierService } from './image-classifier.service';
export class ImageClassifierController {
constructor(
private readonly imageClassifierService: ImageClassifierService,
) {}
) { }
@Post('/tagImage')
@Post('/tag-image')
async tagImage(@Body('thumbnailPath') thumbnailPath: string) {
return await this.imageClassifierService.tagImage(thumbnailPath);
}

View File

@@ -8,14 +8,14 @@ async function bootstrap() {
await app.listen(3001, () => {
if (process.env.NODE_ENV == 'development') {
Logger.log(
'Running Immich Microservices in DEVELOPMENT environment',
'Running Immich Machine Learning in DEVELOPMENT environment',
'IMMICH MICROSERVICES',
);
}
if (process.env.NODE_ENV == 'production') {
Logger.log(
'Running Immich Microservices in PRODUCTION environment',
'Running Immich Machine Learning in PRODUCTION environment',
'IMMICH MICROSERVICES',
);
}

View File

@@ -1,13 +1,14 @@
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')
@Post('/detect-object')
async detectObject(@Body('thumbnailPath') thumbnailPath: string) {
return await this.objectDetectionService.detectObject(thumbnailPath);
}

View File

@@ -1 +0,0 @@
devenv/

View File

@@ -1,3 +0,0 @@
__pycache__/
devenv/
app/upload

View File

@@ -1,25 +0,0 @@
## GPU Build
# FROM tensorflow/tensorflow:latest-gpu as gpu
# WORKDIR /code
# COPY ./requirements.txt /code/requirements.txt
# RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
# COPY ./app /code/app
## CPU BUILD
FROM python:3.8 as cpu
RUN apt-get update
RUN apt-get install ffmpeg libsm6 libxext6 -y
WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./app /code/app

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -1,37 +0,0 @@
from tensorflow.keras.applications import InceptionV3
from tensorflow.keras.applications.inception_v3 import preprocess_input, decode_predictions
from tensorflow.keras.preprocessing import image
import numpy as np
from PIL import Image
import cv2
IMG_SIZE = 299
PREDICTION_MODEL = InceptionV3(weights='imagenet')
def classify_image(image_path: str):
img_path = f'./app/{image_path}'
# img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
target_image = cv2.imread(img_path, cv2.IMREAD_UNCHANGED)
resized_target_image = cv2.resize(target_image, (IMG_SIZE, IMG_SIZE))
x = image.img_to_array(resized_target_image)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
preds = PREDICTION_MODEL.predict(x)
result = decode_predictions(preds, top=3)[0]
payload = []
for _, value, _ in result:
payload.append(value)
return payload
def warm_up():
img_path = f'./app/test.png'
img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
PREDICTION_MODEL.predict(x)

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +0,0 @@
from pydantic import BaseModel
from fastapi import FastAPI
from .object_detection import object_detection
from .image_classifier import image_classifier
from tf2_yolov4.anchors import YOLOV4_ANCHORS
from tf2_yolov4.model import YOLOv4
HEIGHT, WIDTH = (640, 960)
# Warm up model
image_classifier.warm_up()
app = FastAPI()
class TagImagePayload(BaseModel):
thumbnail_path: str
@app.post("/tagImage")
async def post_root(payload: TagImagePayload):
image_path = payload.thumbnail_path
if image_path[0] == '.':
image_path = image_path[2:]
return image_classifier.classify_image(image_path=image_path)
@app.get("/")
async def test():
object_detection.run_detection()
# image = tf.io.read_file("./app/cars.jpg")
# image = tf.image.decode_image(image)
# image = tf.image.resize(image, (HEIGHT, WIDTH))
# images = tf.expand_dims(image, axis=0) / 255.0
# model = YOLOv4(
# (HEIGHT, WIDTH, 3),
# 80,
# YOLOV4_ANCHORS,
# "darknet",
# )

View File

@@ -1,4 +0,0 @@
def run_detection():
print("run detection")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -1,8 +0,0 @@
opencv-python==4.5.5.64
fastapi>=0.68.0,<0.69.0
pydantic>=1.8.0,<2.0.0
uvicorn>=0.15.0,<0.16.0
tensorflow==2.8.0
numpy==1.22.2
pillow==9.0.1
tf2_yolov4==0.1.0

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
@@ -23,4 +23,11 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
</manifest>

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.

View File

@@ -0,0 +1,2 @@
* Update to Material Design 3
* Fixed back button navigation - no longer return back to the home page

View File

@@ -0,0 +1 @@
* Added announcement pop-up when a new released is pushed out in Github.

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>
@@ -52,7 +58,7 @@
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@@ -68,5 +74,13 @@
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
</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.11.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,11 @@ const String serverEndpointKey = 'immichBoxServerEndpoint';
// Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox";
const String savedLoginInfoKey = "immichSavedLoginInfoKey";
// Backup Info
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox";
const String backupInfoKey = "immichBackupAlbumInfoKey";
// Github Release Info
const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox";
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey";

View File

@@ -3,22 +3,31 @@ 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/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/release_info.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'constants/hive_box.dart';
void main() async {
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
await Hive.openBox(hiveGithubReleaseInfoBox);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
@@ -43,10 +52,18 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
case AppLifecycleState.resumed:
debugPrint("[APP STATE] resumed");
ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed;
ref.watch(backupProvider.notifier).resumeBackup();
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
if (isAuthenticated) {
ref.watch(backupProvider.notifier).resumeBackup();
ref.watch(assetProvider.notifier).getAllAsset();
ref.watch(serverInfoProvider.notifier).getServerVersion();
}
ref.watch(websocketProvider.notifier).connect();
ref.watch(assetProvider.notifier).getAllAsset();
ref.watch(serverInfoProvider.notifier).getServerVersion();
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
break;
@@ -71,7 +88,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
}
Future<void> initApp() async {
WidgetsBinding.instance?.addObserver(this);
WidgetsBinding.instance.addObserver(this);
}
@override
@@ -82,7 +99,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
@override
void dispose() {
WidgetsBinding.instance?.removeObserver(this);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@@ -90,6 +107,8 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
@override
Widget build(BuildContext context) {
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Stack(
@@ -98,6 +117,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
title: 'Immich',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primarySwatch: Colors.indigo,
fontFamily: 'WorkSans',
@@ -115,6 +135,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
),
const ImmichLoadingOverlay(),
const VersionAnnouncementOverlay(),
],
),
);

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

@@ -0,0 +1,322 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
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 {
const BackupControllerPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
bool shouldBackup =
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
useEffect(() {
if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
ref.read(backupProvider.notifier).getBackupInfo();
}
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success');
return null;
}, []);
Widget _buildStorageInformation() {
return ListTile(
leading: Icon(
Icons.storage_rounded,
color: Theme.of(context).primaryColor,
),
title: const Text(
"Server Storage",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearPercentIndicator(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 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'),
),
],
),
),
);
}
ListTile _buildBackupController() {
var backUpOption = _authenticationState.deviceInfo.isAutoBackup ? "on" : "off";
var isAutoBackup = _authenticationState.deviceInfo.isAutoBackup;
var backupBtnText = _authenticationState.deviceInfo.isAutoBackup ? "off" : "on";
return ListTile(
isThreeLine: true,
leading: isAutoBackup
? Icon(
Icons.cloud_done_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(Icons.cloud_off_rounded),
title: Text(
"Back up is $backUpOption",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
!isAutoBackup
? const Text(
"Turn on backup to automatically upload new assets to the server.",
style: TextStyle(fontSize: 14),
)
: Container(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: const BorderSide(
width: 1,
color: Color.fromARGB(255, 220, 220, 220),
),
),
onPressed: () {
isAutoBackup
? ref.watch(authenticationProvider.notifier).setAutoBackup(false)
: ref.watch(authenticationProvider.notifier).setAutoBackup(true);
},
child: Text("Turn $backupBtnText Backup", style: const TextStyle(fontWeight: FontWeight.bold)),
),
)
],
),
),
);
}
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(
style: OutlinedButton.styleFrom(
enableFeedback: true,
side: const BorderSide(
width: 1,
color: Color.fromARGB(255, 220, 220, 220),
),
),
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(fontSize: 16, fontWeight: FontWeight.bold),
),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
AutoRouter.of(context).pop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
)),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
// crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
"Backup Information",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
_buildFolderSelectionTile(),
BackupInfoCard(
title: "Total",
subtitle: "All unique photos and videos from selected albums",
info: "${backupState.allUniqueAssets.length}",
),
BackupInfoCard(
title: "Backup",
subtitle: "Photos and videos from selected albums that are backup",
info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
),
BackupInfoCard(
title: "Remainder",
subtitle: "Photos and videos that has not been backing up from selected albums",
info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
),
const Divider(),
_buildBackupController(),
const Divider(),
_buildStorageInformation(),
const Divider(),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(children: [
const Text("Backup Progress:"),
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
backupState.backupProgress == BackUpProgressEnum.inProgress
? const CircularProgressIndicator.adaptive()
: const Text("Done"),
]),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
child: backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Colors.red[300],
onPrimary: Colors.grey[50],
),
onPressed: () {
ref.read(backupProvider.notifier).cancelBackup();
},
child: const Text("Cancel"),
)
: ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
),
onPressed: shouldBackup
? () {
ref.read(backupProvider.notifier).startBackupProcess();
}
: null,
child: const Text("Start Backup"),
),
),
)
],
),
),
);
}
}

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) {
@@ -110,7 +109,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
? const Icon(Icons.backup_rounded)
: Badge(
padding: const EdgeInsets.all(4),
elevation: 1,
elevation: 2,
position: BadgePosition.bottomEnd(bottom: -4, end: -4),
badgeColor: Colors.white,
badgeContent: const Icon(
@@ -118,7 +117,6 @@ class ImmichSliverAppBar extends ConsumerWidget {
size: 8,
),
child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller',
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
@@ -131,7 +129,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,56 @@ 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,
),
),
),
),
),
],
),
Text(
"${_authState.firstName} ${_authState.lastName}",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
const Padding(padding: EdgeInsets.all(8)),
Text(
_authState.userEmail,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
style: TextStyle(color: Colors.grey[800], fontSize: 12),
)
],
),
@@ -97,7 +194,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 {
@@ -153,9 +153,12 @@ class LoginButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
style: ButtonStyle(
style: ElevatedButton.styleFrom(
visualDensity: VisualDensity.standard,
padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 10, horizontal: 25)),
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
elevation: 2,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
),
onPressed: () async {
// This will remove current cache asset state of previous user login.

View File

@@ -79,14 +79,14 @@ class SearchResultPage extends HookConsumerWidget {
return Chip(
label: Wrap(
spacing: 5,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
currentSearchTerm.value,
style: TextStyle(color: Theme.of(context).primaryColor),
maxLines: 1,
),
Text(
currentSearchTerm.value,
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 13, fontWeight: FontWeight.bold),
maxLines: 1,
),
Icon(
Icons.close_rounded,

View File

@@ -13,15 +13,14 @@ class AlbumActionOutlinedButton extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: OutlinedButton.icon(
style: ButtonStyle(
padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 0, horizontal: 10)),
shape: MaterialStateProperty.resolveWith<OutlinedBorder>(
(_) => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
side: MaterialStateProperty.resolveWith<BorderSide>(
(_) => const BorderSide(width: 1, color: Color.fromARGB(255, 158, 158, 158)),
side: const BorderSide(
width: 1,
color: Color.fromARGB(255, 215, 215, 215),
),
),
icon: Icon(iconData, size: 15),

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