Compare commits

..

71 Commits

Author SHA1 Message Date
Alex Tran
25848b78f9 Up version for release 2022-10-26 11:24:04 -05:00
Alex
f94176a910 feat(web) dark mode (#867) 2022-10-26 11:10:48 -05:00
Alex Tran
ae96508e15 Fixed unit test 2022-10-25 22:15:17 -05:00
Alex
95ebf815eb feat(web) styling server stats page (#866) 2022-10-25 21:41:46 -05:00
Jonas Janz
b713fb5650 feat(docker) revert ubuntu base image (#863)
* feat(docker) revert ubuntu base image

This PR reverts the base image for immich-server back to alpine

Adds LICENSE to all Images
Quiets apt-get commands when building
ensures write-permission for root group on app folders

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>

* Test build old Docker content

* Revert and retry

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-10-25 14:18:37 -05:00
Alex
6159c83fd2 feat(mobile) duplicated asset upload handling mechanism (#853) 2022-10-25 09:51:03 -05:00
Jonas Janz
f1af17bf4d feat(immich-server) use ubuntu base-image (#851)
this changes the base-image for immich-server from
`node:16-alpine3.14`
to
`node:16-slim`

There is an open issue with alpine DNS resolving which
breaks immich-microservice when deployed on
kubernetes.

This fixes https://github.com/immich-app/immich-charts/issues/4

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-10-24 14:59:07 -05:00
Alex
a87c1c1210 fix(mobile) not possible to sign out when option is enable (#860) 2022-10-24 14:45:58 -05:00
Alex
e63d165b65 chore(server) add workflow dispatcher to sdk repository (#859) 2022-10-24 12:55:16 -05:00
Alex Tran
9411770253 update readme 2022-10-23 22:21:48 -05:00
Alex Tran
dc80ac1c88 Remove openapi generator - move to TeamCity CI/CD for this job 2022-10-23 18:19:21 -05:00
Alex Tran
bb055628cc Fixed api generation action 2022-10-23 17:56:16 -05:00
Alex Tran
390bcdb8c6 Fixed api generation action 2022-10-23 17:53:11 -05:00
Alex Tran
d95bcb46ad Fixed api generation action 2022-10-23 17:52:52 -05:00
Alex
7b954e21e7 fix(server): add permission for server stats api (#854) 2022-10-23 17:01:41 -05:00
Zeeshan Khan
a6eea4d096 feat(web) add asset count stats on admin page (#843) 2022-10-23 16:54:54 -05:00
Alex
2c189d5c78 fix(server): force best effort to decode thumbnail image (#847) 2022-10-22 11:40:25 -05:00
Alex
85a80fd032 Added changlog 2022-10-21 13:19:04 -05:00
Zeeshan Khan
0309b47515 fixes(mobile) back navigation issue on android (#841) 2022-10-21 13:05:44 -05:00
bo0tzz
95d8f60389 feat(server)Log username and IP address on failed login attempt 2022-10-21 11:04:01 -05:00
Alex Tran
1ec7122381 Up version for release 2022-10-19 20:07:53 -05:00
Alex
061b229e12 feat(mobile): Cache assets and albums for faster loading speed
feat(mobile): Cache assets and albums for faster loading speed
2022-10-19 15:53:15 -05:00
Matthias Rupp
3617433858 Refactor abstract class to separate file 2022-10-19 22:03:54 +02:00
Alex
d6d525cc1b fix(mobile) back button navigation Android
fixes #310 back button navigation
2022-10-19 14:51:48 -05:00
Alex
e752290458 Merge pull request #839 from immich-app/dependabot/github_actions/docker/setup-buildx-action-2.2.1
chore(deps): bump docker/setup-buildx-action from 2.1.0 to 2.2.1
2022-10-18 09:27:47 -05:00
Matthias Rupp
d77e25425e Add cache for shared albums 2022-10-18 14:06:35 +02:00
dependabot[bot]
028c0249a3 chore(deps): bump docker/setup-buildx-action from 2.1.0 to 2.2.1
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.1.0 to 2.2.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.1.0...v2.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-18 10:45:12 +00:00
Zeeshan Khan
a3ca5307a5 fixes #310 back button navigation 2022-10-17 13:04:17 -05:00
Matthias Rupp
6796462b13 Switch to plain fs based caching mechanism 2022-10-17 18:02:43 +02:00
Matthias Rupp
d08475d5af Switch to lazyBox 2022-10-17 16:40:51 +02:00
Matthias Rupp
d310c77fc8 Add album list response caching 2022-10-17 14:53:27 +02:00
Matthias Rupp
75d8ca1306 Invalidation on logout and timing measurements 2022-10-16 09:50:31 +02:00
Matthias Rupp
894eea739e JSON based caching 2022-10-15 23:20:15 +02:00
Matthias Rupp
1156290377 Add asset response cache 2022-10-14 23:57:55 +02:00
Alex Tran
c271f0c224 Up version for release 2022-10-14 16:21:26 -05:00
Alex
a7f14dc103 feat(mobile): cosmetic update 2022-10-14 16:17:14 -05:00
Alex Tran
f05d5bdb9e Added haptic feedback to incorect login 2022-10-14 16:13:35 -05:00
Alex Tran
e99c400f59 Added haptic feedback to tab bar switching 2022-10-14 16:04:21 -05:00
Alex Tran
e38166837d Merge branch 'main' of github.com:immich-app/immich into dev/mobile-cosmetic-improvement 2022-10-14 15:58:26 -05:00
Alex
d43a08eb71 feat(mobile) integrate new grid system to upstream 2022-10-14 15:39:16 -05:00
Alex Tran
293e713af6 Hide bottom app bar when multiselect enabled 2022-10-14 15:37:15 -05:00
Alex Tran
03866b4c31 Merge branch 'feature/albums-new-grid' of github.com:immich-app/immich into feature/albums-new-grid 2022-10-14 14:52:07 -05:00
Alex Tran
4f2c08525f Merge upstream 2022-10-14 14:52:00 -05:00
Matthias Rupp
2c12f53937 Fix storage indicator settings 2022-10-14 21:17:23 +02:00
Alex Tran
c88e5f9be2 Add haptic feedback on swiping asset 2022-10-14 11:26:10 -05:00
Alex Tran
0f51a9794e fixed delete dialog colors 2022-10-14 11:15:19 -05:00
Alex Tran
edd1f49e57 Update text for foreground backup to make things clearer 2022-10-14 10:50:40 -05:00
Alex
4df0cf2d07 Merge pull request #821 from immich-app/feature/jwt-bits-warning
Log a warning if JWT_SECRET key does not have enough bits
2022-10-14 08:42:27 -05:00
bo0tzz
87ba99755b Remove unused variable 2022-10-13 22:17:31 +02:00
bo0tzz
c03f860f8e Log a warning if JWT_SECRET key does not have enough bits 2022-10-13 21:54:29 +02:00
Alex
f2e0e3f345 Merge pull request #820 from immich-app/dependabot/github_actions/docker/setup-qemu-action-2.1.0
chore(deps): bump docker/setup-qemu-action from 2.0.0 to 2.1.0
2022-10-13 08:44:37 -05:00
dependabot[bot]
fee652dfd7 chore(deps): bump docker/setup-qemu-action from 2.0.0 to 2.1.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2.0.0...v2.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-13 10:55:56 +00:00
Alex
839446a88d Merge pull request #816 from immich-app/dependabot/github_actions/docker/build-push-action-3.2.0
chore(deps): bump docker/build-push-action from 3.1.1 to 3.2.0
2022-10-12 08:50:14 -05:00
Alex
028b8c8bcc Merge pull request #815 from immich-app/dependabot/github_actions/docker/setup-buildx-action-2.1.0
chore(deps): bump docker/setup-buildx-action from 2.0.0 to 2.1.0
2022-10-12 08:50:02 -05:00
dependabot[bot]
64b1d4ca3b chore(deps): bump docker/build-push-action from 3.1.1 to 3.2.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3.1.1 to 3.2.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3.1.1...v3.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-12 10:47:08 +00:00
dependabot[bot]
c6cbee6563 chore(deps): bump docker/setup-buildx-action from 2.0.0 to 2.1.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.0.0...v2.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-12 10:47:05 +00:00
Alex
a406f6e7cc feat(server): Remove default JWT_SECRET value in .env 2022-10-12 03:52:06 -05:00
bo0tzz
9869b92c2b Generate random JWT_SECRET value in install.sh 2022-10-12 09:34:10 +02:00
bo0tzz
00549eed79 Uncomment JWT_SECRET in default .env
Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-10-12 09:18:43 +02:00
bo0tzz
0c4968dc30 Fix: Remove default JWT_SECRET value in .env 2022-10-11 21:13:37 +02:00
Alex
704335c898 Merge pull request #801 from EvilOlaf/patch-1 2022-10-10 05:06:07 -05:00
Werner
ec74feea5a Typo/minor cosmetics 2022-10-10 07:46:23 +02:00
Matthias Rupp
6ab6507db9 Revert changes to albums 2022-10-08 13:18:45 +02:00
Matthias Rupp
3c807ae86e Exernalize multiselect state 2022-10-08 13:08:56 +02:00
Matthias Rupp
6b84534632 Get rid of home page state provider 2022-10-06 22:41:56 +02:00
Matthias Rupp
a117e897ca Move selection logic to asset grid class 2022-10-01 19:19:40 +02:00
Matthias Rupp
347ac70063 Make new asset grid the default 2022-10-01 10:38:11 +02:00
Matthias Rupp
50842ef815 Add tests 2022-09-30 11:38:00 +02:00
Matthias Rupp
1970a64f6f Use new asset grid for search result page 2022-09-30 11:05:54 +02:00
Matthias Rupp
dd71a53f5e Hide scroll handle for lists < 100 assets 2022-09-30 10:47:31 +02:00
Matthias Rupp
8440d9890c Improve scrolling performance in albums and search 2022-09-29 21:53:35 +02:00
172 changed files with 3895 additions and 1646 deletions

View File

@@ -17,17 +17,17 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- 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.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./server
file: ./server/Dockerfile
@@ -45,17 +45,17 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- 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.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -72,17 +72,17 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- 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.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./web
file: ./web/Dockerfile
@@ -100,17 +100,17 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Proxy
uses: docker/build-push-action@v3.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./nginx
file: ./nginx/Dockerfile

View File

@@ -17,10 +17,10 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -28,7 +28,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich Mono Repo
uses: docker/build-push-action@v3.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./server
file: ./server/Dockerfile
@@ -47,10 +47,10 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -58,7 +58,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -76,10 +76,10 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -87,7 +87,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./web
file: ./web/Dockerfile
@@ -106,10 +106,10 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -117,7 +117,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Proxy
uses: docker/build-push-action@v3.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./nginx
file: ./nginx/Dockerfile

View File

@@ -22,11 +22,11 @@ jobs:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -35,7 +35,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-server release
uses: docker/build-push-action@v3.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./server
file: ./server/Dockerfile
@@ -58,17 +58,17 @@ jobs:
with:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- 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.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -94,11 +94,11 @@ jobs:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -107,7 +107,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-web release
uses: docker/build-push-action@v3.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./web
file: ./web/Dockerfile
@@ -134,11 +134,11 @@ jobs:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -147,7 +147,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-proxy release
uses: docker/build-push-action@v3.1.1
uses: docker/build-push-action@v3.2.0
with:
context: ./nginx
file: ./nginx/Dockerfile

View File

@@ -0,0 +1,21 @@
name: Update Immich SDK
on:
workflow_dispatch:
push:
branches: ["main"]
jobs:
update-sdk-repos:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'immich-app',
repo: 'immich-sdk-typescript-axios',
workflow_id: 'build.yml',
ref: 'main'
})

View File

@@ -1,83 +0,0 @@
name: Generate OpenAPI SDK
on:
workflow_dispatch:
push:
branches: [main]
jobs:
generate-typescript-axios:
runs-on: ubuntu-latest
name: OpenAPI Generator
steps:
# Checkout your code
- name: Checkout
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_TOKEN }}
# Use the action to generate a client package
# This uses the default path for the openapi document and thus assumes there is an openapi.json in the current workspace.
- name: Generate Typescript Axios Client
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: typescript-axios
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
# Do something with the generated client (likely publishing it somewhere)
- name: Push to typescript repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd typescript-axios-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-typescript-axios.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'
- name: Generate Dart SDK
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: dart
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
- name: Push to Dart repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd dart-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-dart.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'
- name: Generate Rust SDK
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: rust
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
- name: Push to Rust repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd rust-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-rust.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'

View File

@@ -53,7 +53,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
# Features
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development, there will be continuous functions, features and api changes.
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development. There will be continuous functions, features and api changes.
| Features | Mobile | Web |
| - | - | - |
@@ -116,13 +116,12 @@ There are several services that compose Immich:
# Installation
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as `client_max_body_size` in the case of NGINX).
## Testing one-step installation (not recommended for production)
## Testing One-step installation (not recommended for production)
> ⚠️ *This installation method is for evaluating Immich before further customization to meet the users' needs.*
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
*Applicable system: Ubuntu, Debian, MacOS*
*Applicable operating systems: Ubuntu, Debian, MacOS*
- In the shell, from the directory of your choice, run the following command:
@@ -197,6 +196,10 @@ If you have installed, you can update the application by navigate to the directo
```bash
docker-compose pull && docker-compose up -d
```
# Unraid Installation
Please follow this [article](https://mfaz.dev/posts/immich-unraid/) for a tutorial on how to install Immich on Unraid
# Mobile app
@@ -204,7 +207,7 @@ docker-compose pull && docker-compose up -d
| - | - | - |
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
> *The Play/App Store version might be lagging behind the latest release due to their review process.*
# App Beta release channel

View File

@@ -38,7 +38,10 @@ LOG_LEVEL=simple
# JWT SECRET
###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
# This JWT_SECRET is used to sign the authentication keys for user login
# You should set it to a long randomly generated value
# You can use this command to generate one: openssl rand -base64 128
JWT_SECRET=
###################################################################################
# Reverse Geocoding

View File

@@ -18,33 +18,37 @@ get_release_version() {
create_immich_directory() {
echo "Creating Immich directory..."
mkdir -p ./immich-app/immich-data
cd ./immich-app
}
download_docker_compose_file() {
echo "Downloading docker-compose.yml..."
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./docker-compose.yml >/dev/null 2>&1
}
download_dot_env_file() {
echo "Downloading .env file..."
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./.env >/dev/null 2>&1
}
replace_env_value() {
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|$1=.*|$1=$2|" ./.env
else
sed -i "s|$1=.*|$1=$2|" ./.env
fi
}
populate_upload_location() {
echo "Populating default UPLOAD_LOCATION value..."
upload_location=$(pwd)/immich-data
replace_env_value "UPLOAD_LOCATION" $upload_location
}
cd ./immich-app/immich-data
upload_location=$(pwd)
# Replace value of UPLOAD_LOCATION in .env with upload_location path
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
else
sed -i "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
fi
cd ..
generate_jwt_secret() {
echo "Generating JWT_SECRET value..."
jwt_secret=$(openssl rand -base64 128)
replace_env_value "JWT_SECRET" $jwt_secret
}
start_docker_compose() {
@@ -88,4 +92,5 @@ create_immich_directory
download_docker_compose_file
download_dot_env_file
populate_upload_location
generate_jwt_secret
start_docker_compose

View File

@@ -7,8 +7,9 @@ WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN apt-get update > /dev/null \
&& apt-get install --no-install-recommends -y gcc g++ make cmake python3 python3-pip ffmpeg > /dev/null \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN npm ci
RUN npm rebuild @tensorflow/tfjs-node --build-from-source
@@ -23,6 +24,9 @@ FROM node:16-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
@@ -30,13 +34,18 @@ COPY entrypoint.sh ./
RUN mkdir -p /usr/src/app/dist \
&& mkdir -p /usr/src/app/node_modules \
&& apt-get update \
&& apt-get install -y ffmpeg \
&& rm -rf /var/cache/apt/lists
&& mkdir -p /usr/src/app/.reverse-geocoding-dump \
&& apt-get update > /dev/null \
&& apt-get install --no-install-recommends -y ffmpeg > /dev/null \
&& apt-get clean \
&& rm -rf /var/cache/apt/lists/*
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production
# CMD [ "node", "dist/main" ]
RUN chown -R node:0 /usr/src/app \
&& chmod -R g=u /usr/src/app
RUN addgroup node root

21
machine-learning/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 49,
"android.injected.version.name" => "1.31.0",
"android.injected.version.code" => 52,
"android.injected.version.name" => "1.33.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1,2 @@
* Integrate new grid system to the main timeline.
* Minor UI update.

View File

@@ -0,0 +1 @@
* Improved slow initial loading for large amount of asset.

View File

@@ -0,0 +1,3 @@
* Fixed back button navigation
* Added duplicated asset upload handling mechanism
* Fixed cannot signout completely when "Save Logged In" is checked

View File

@@ -24,6 +24,7 @@
"backup_controller_page_backup_selected": "Ausgewählt: ",
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
"backup_controller_page_cancel": "Abbrechen",
"backup_background_service_default_notification": "Suche nach neuen assets…",
"backup_controller_page_created": "Erstellt: {}",
"backup_controller_page_desc_backup": "Aktiviere die Sicherung um Elemente automatisch auf den Server zu laden.",
"backup_controller_page_excluded": "Ausgeschlossen: ",
@@ -123,4 +124,4 @@
"version_announcement_overlay_text_2": "Bitte nehm dir die Zeit und lese das ",
"version_announcement_overlay_text_3": " und achte darauf, dass deine docker-compose und .env Dateien aktuell sind, vor allem wenn du ein System für automatische Updates benutzt (z.B. Watchtower).",
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89"
}
}

View File

@@ -46,7 +46,7 @@
"backup_controller_page_backup_sub": "Backed up photos and videos",
"backup_controller_page_cancel": "Cancel",
"backup_controller_page_created": "Created on: {}",
"backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.",
"backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.",
"backup_controller_page_excluded": "Excluded: ",
"backup_controller_page_failed": "Failed ({})",
"backup_controller_page_filename": "File name: {} [{}]",
@@ -58,14 +58,14 @@
"backup_controller_page_select": "Select",
"backup_controller_page_server_storage": "Server Storage",
"backup_controller_page_start_backup": "Start Backup",
"backup_controller_page_status_off": "Backup is off",
"backup_controller_page_status_on": "Backup is on",
"backup_controller_page_status_off": "Automatic foreground backup is off",
"backup_controller_page_status_on": "Automatic foreground backup is on",
"backup_controller_page_storage_format": "{} of {} used",
"backup_controller_page_to_backup": "Albums to be backup",
"backup_controller_page_total": "Total",
"backup_controller_page_total_sub": "All unique photos and videos from selected albums",
"backup_controller_page_turn_off": "Turn off Backup",
"backup_controller_page_turn_on": "Turn on Backup",
"backup_controller_page_turn_off": "Turn off foreground backup",
"backup_controller_page_turn_on": "Turn on foreground backup",
"backup_controller_page_uploading_file_info": "Uploading file info",
"backup_err_only_album": "Cannot remove the only album",
"backup_info_card_assets": "assets",
@@ -171,8 +171,5 @@
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"experimental_settings_title": "Experimental",
"experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_new_asset_list_subtitle": "Work in progress",
"settings_require_restart": "Please restart Immich to apply this setting"
}
"experimental_settings_subtitle": "Use at your own risk!"
}

View File

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

View File

@@ -25,3 +25,7 @@ const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
// Duplicate asset
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1

View File

@@ -7,10 +7,10 @@ import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.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/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -31,12 +31,14 @@ void main() async {
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
await Hive.openBox(hiveGithubReleaseInfoBox);
await Hive.openBox(userSettingInfoBox);
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(

View File

@@ -1,22 +1,35 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:openapi/api.dart';
class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
AlbumNotifier(this._albumService) : super([]);
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
final AlbumService _albumService;
final AlbumCacheService _albumCacheService;
_cacheState() {
_albumCacheService.put(state);
}
getAllAlbums() async {
if (await _albumCacheService.isValid() && state.isEmpty) {
state = await _albumCacheService.get();
}
List<AlbumResponseDto>? albums =
await _albumService.getAlbums(isShared: false);
if (albums != null) {
state = albums;
_cacheState();
}
}
deleteAlbum(String albumId) {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
}
Future<AlbumResponseDto?> createAlbum(
@@ -28,6 +41,8 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
if (album != null) {
state = [...state, album];
_cacheState();
return album;
}
return null;
@@ -36,5 +51,8 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
final albumProvider =
StateNotifierProvider<AlbumNotifier, List<AlbumResponseDto>>((ref) {
return AlbumNotifier(ref.watch(albumServiceProvider));
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(albumCacheServiceProvider),
);
});

View File

@@ -1,12 +1,18 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:openapi/api.dart';
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
SharedAlbumNotifier(this._sharedAlbumService) : super([]);
SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]);
final AlbumService _sharedAlbumService;
final SharedAlbumCacheService _sharedAlbumCacheService;
_cacheState() {
_sharedAlbumCacheService.put(state);
}
Future<AlbumResponseDto?> createSharedAlbum(
String albumName,
@@ -22,6 +28,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
if (newAlbum != null) {
state = [...state, newAlbum];
_cacheState();
}
return newAlbum;
@@ -33,16 +40,22 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}
getAllSharedAlbums() async {
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
state = await _sharedAlbumCacheService.get();
}
List<AlbumResponseDto>? sharedAlbums =
await _sharedAlbumService.getAlbums(isShared: true);
if (sharedAlbums != null) {
state = sharedAlbums;
_cacheState();
}
}
deleteAlbum(String albumId) async {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
}
Future<bool> leaveAlbum(String albumId) async {
@@ -50,6 +63,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
if (res) {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
return true;
} else {
return false;
@@ -72,7 +86,10 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<AlbumResponseDto>>((ref) {
return SharedAlbumNotifier(ref.watch(albumServiceProvider));
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});
final sharedAlbumDetailProvider = FutureProvider.autoDispose

View File

@@ -0,0 +1,49 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';
class BaseAlbumCacheService extends JsonCache<List<AlbumResponseDto>> {
BaseAlbumCacheService(super.cacheFileName);
@override
void put(List<AlbumResponseDto> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
@override
Future<List<AlbumResponseDto>> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = mapList
.map((e) => AlbumResponseDto.fromJson(e))
.whereNotNull()
.toList();
return responseData;
} catch (e) {
debugPrint(e.toString());
return [];
}
}
}
class AlbumCacheService extends BaseAlbumCacheService {
AlbumCacheService() : super("album_cache");
}
class SharedAlbumCacheService extends BaseAlbumCacheService {
SharedAlbumCacheService() : super("shared_album_cache");
}
final albumCacheServiceProvider = Provider(
(ref) => AlbumCacheService(),
);
final sharedAlbumCacheServiceProvider = Provider(
(ref) => SharedAlbumCacheService(),
);

View File

@@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';

View File

@@ -151,7 +151,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
actions: [
TextButton(
style: TextButton.styleFrom(
primary: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).primaryColor,
),
onPressed:
sharedUsersList.value.isEmpty ? null : _createSharedAlbum,

View File

@@ -1,5 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
import 'package:hive/hive.dart';
@@ -111,6 +112,9 @@ class GalleryViewerPage extends HookConsumerWidget {
: const BouncingScrollPhysics(),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
HapticFeedback.selectionClick();
},
itemBuilder: (context, index) {
initState(index);

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/backup/background_service/localization.dar
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -316,10 +317,13 @@ class BackgroundService {
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox(userSettingInfoBox);
await Hive.openBox(backgroundBackupInfoBox);
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
@@ -410,7 +414,7 @@ class BackgroundService {
final bool ok = await backupService.backupAsset(
toUpload,
_cancellationToken!,
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
notifySingleProgress ? _onProgress : (sent, total) {},
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
_onBackupError,
@@ -429,7 +433,7 @@ class BackgroundService {
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
debugPrint("Uploaded $deviceAssetId from $deviceId");
_uploadedAssetsCount++;
_updateNotification(

View File

@@ -21,7 +21,9 @@ Future<bool> loadTranslations() async {
await controller.loadTranslations();
return Localization.load(controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations);
return Localization.load(
controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations,
);
}

View File

@@ -45,8 +45,10 @@ class ErrorUploadAsset extends Equatable {
List<Object> get props {
return [
id,
createdAt,
fileName,
fileType,
asset,
errorMessage,
];
}

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
@@ -296,6 +297,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Those assets are unique and are used as the total assets
///
Future<void> _updateBackupAssetCount() async {
Set<String> duplicatedAssetIds = _backupService.getDuplicatedAssetIds();
Set<AssetEntity> assetsFromSelectedAlbums = {};
Set<AssetEntity> assetsFromExcludedAlbums = {};
@@ -326,9 +328,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums
Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
);
if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device");
state = state.copyWith(
@@ -455,14 +463,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId
},
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
);
void _onAssetUploaded(
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
if (isDuplicated) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId
},
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
);
}
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
@@ -564,6 +584,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
albums.lastExcludedBackupTime,
);
}
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
state = state.copyWith(
backupProgress: previous,
@@ -608,6 +629,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
}
try {
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
}
try {
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
await Hive.box(backgroundBackupInfoBox).close();

View File

@@ -19,6 +19,8 @@ import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http;
import '../models/hive_duplicated_assets.model.dart';
final backupServiceProvider = Provider(
(ref) => BackupService(
ref.watch(apiServiceProvider),
@@ -41,6 +43,29 @@ class BackupService {
}
}
void _saveDuplicatedAssetIdToLocalStorage(List<String> deviceAssetIds) {
HiveDuplicatedAssets duplicatedAssets =
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.get(duplicatedAssetsKey) ??
HiveDuplicatedAssets(duplicatedAssetIds: []);
duplicatedAssets.duplicatedAssetIds =
{...duplicatedAssets.duplicatedAssetIds, ...deviceAssetIds}.toList();
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.put(duplicatedAssetsKey, duplicatedAssets);
}
/// Get duplicated asset id from Hive storage
Set<String> getDuplicatedAssetIds() {
HiveDuplicatedAssets duplicatedAssets =
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.get(duplicatedAssetsKey) ??
HiveDuplicatedAssets(duplicatedAssetIds: []);
return duplicatedAssets.duplicatedAssetIds.toSet();
}
/// Returns all assets newer than the last successful backup per album
Future<List<AssetEntity>> buildUploadCandidates(
HiveBackupAlbums backupAlbums,
@@ -140,34 +165,47 @@ class BackupService {
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
List<AssetEntity> candidates,
) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
if (candidates.length < 10) {
final List<CheckDuplicateAssetResponseDto?> duplicateResponse =
await Future.wait(
candidates.map(
(e) => _apiService.assetApi.checkDuplicateAsset(
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId),
),
if (candidates.isEmpty) {
return candidates;
}
final Set<String> duplicatedAssetIds = getDuplicatedAssetIds();
candidates = duplicatedAssetIds.isEmpty
? candidates
: candidates
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
.toList();
if (candidates.isEmpty) {
return candidates;
}
final Set<String> existing = {};
try {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetApi.checkExistingAssets(
CheckExistingAssetsDto(
deviceAssetIds: candidates.map((e) => e.id).toList(),
deviceId: deviceId,
),
);
return candidates
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false)
.toList();
} else {
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return candidates;
if (duplicates != null) {
existing.addAll(duplicates.existingIds);
}
} on ApiException {
// workaround for older server versions or when checking for too many assets at once
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase != null) {
existing.addAll(allAssetsInDatabase);
}
final Set<String> inDb = allAssetsInDatabase.toSet();
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
}
return existing.isEmpty
? candidates
: candidates.whereNot((e) => existing.contains(e.id)).toList();
}
Future<bool> backupAsset(
Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(String, String, bool) uploadSuccessCb,
Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb,
@@ -176,6 +214,7 @@ class BackupService {
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file;
bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
for (var entity in assetList) {
try {
@@ -235,8 +274,13 @@ class BackupService {
var response = await req.send(cancellationToken: cancelToken);
if (response.statusCode == 201) {
singleAssetDoneCb(entity.id, deviceId);
if (response.statusCode == 200) {
// asset is a duplicate (already exists on the server)
duplicatedAssetIds.add(entity.id);
uploadSuccessCb(entity.id, deviceId, true);
} else if (response.statusCode == 201) {
// stored a new asset on the server
uploadSuccessCb(entity.id, deviceId, false);
} else {
var data = await response.stream.bytesToString();
var error = jsonDecode(data);
@@ -260,7 +304,8 @@ class BackupService {
}
} on http.CancelledException {
debugPrint("Backup was cancelled by the user");
return false;
anyErrors = true;
break;
} catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}");
anyErrors = true;
@@ -271,6 +316,9 @@ class BackupService {
}
}
}
if (duplicatedAssetIds.isNotEmpty) {
_saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds);
}
return !anyErrors;
}

View File

@@ -16,8 +16,8 @@ class AlbumPreviewPage extends HookConsumerWidget {
final assets = useState<List<AssetEntity>>([]);
_getAssetsInAlbum() async {
assets.value =
await album.getAssetListRange(start: 0, end: album.assetCount);
assets.value = await album.getAssetListRange(
start: 0, end: await album.assetCountAsync);
}
useEffect(
@@ -34,7 +34,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
title: Column(
children: [
Text(
"${album.name} (${album.assetCount})",
"${album.name} (${album.assetCountAsync})",
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
Padding(

View File

@@ -158,7 +158,6 @@ class BackupControllerPage extends HookConsumerWidget {
}
void _showBatteryOptimizationInfoToUser() {
final buttonTextColor = Theme.of(context).primaryColor;
showDialog<void>(
context: context,
barrierDismissible: false,
@@ -173,13 +172,14 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(),
),
actions: [
OutlinedButton(
ElevatedButton(
onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication,
),
child: const Text(
"backup_controller_page_background_battery_info_link",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
),
ElevatedButton(
@@ -220,7 +220,12 @@ class BackupControllerPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isBackgroundEnabled)
const Text("backup_controller_page_background_description").tr(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child:
const Text("backup_controller_page_background_description")
.tr(),
),
if (isBackgroundEnabled)
SwitchListTile(
title:
@@ -414,7 +419,6 @@ class BackupControllerPage extends HookConsumerWidget {
ActionChip(
avatar: Icon(
Icons.info,
size: 24,
color: Colors.red[400],
),
elevation: 1,

View File

@@ -1,47 +0,0 @@
import 'package:collection/collection.dart';
import 'package:openapi/api.dart';
class HomePageState {
final bool isMultiSelectEnable;
final Set<AssetResponseDto> selectedItems;
final Set<String> selectedDateGroup;
HomePageState({
required this.isMultiSelectEnable,
required this.selectedItems,
required this.selectedDateGroup,
});
HomePageState copyWith({
bool? isMultiSelectEnable,
Set<AssetResponseDto>? selectedItems,
Set<String>? selectedDateGroup,
}) {
return HomePageState(
isMultiSelectEnable: isMultiSelectEnable ?? this.isMultiSelectEnable,
selectedItems: selectedItems ?? this.selectedItems,
selectedDateGroup: selectedDateGroup ?? this.selectedDateGroup,
);
}
@override
String toString() =>
'HomePageState(isMultiSelectEnable: $isMultiSelectEnable, selectedItems: $selectedItems, selectedDateGroup: $selectedDateGroup)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final setEquals = const DeepCollectionEquality().equals;
return other is HomePageState &&
other.isMultiSelectEnable == isMultiSelectEnable &&
setEquals(other.selectedItems, selectedItems) &&
setEquals(other.selectedDateGroup, selectedDateGroup);
}
@override
int get hashCode =>
isMultiSelectEnable.hashCode ^
selectedItems.hashCode ^
selectedDateGroup.hashCode;
}

View File

@@ -1,95 +1,14 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:openapi/api.dart';
enum RenderAssetGridElementType {
assetRow,
dayTitle,
monthTitle;
}
class RenderAssetGridRow {
final List<AssetResponseDto> assets;
RenderAssetGridRow(this.assets);
}
class RenderAssetGridElement {
final RenderAssetGridElementType type;
final RenderAssetGridRow? assetRow;
final String? title;
final DateTime date;
final List<AssetResponseDto>? relatedAssetList;
RenderAssetGridElement(
this.type, {
this.assetRow,
this.title,
required this.date,
this.relatedAssetList,
});
}
final renderListProvider = StateProvider((ref) {
var assetGroups = ref.watch(assetGroupByDateTimeProvider);
var settings = ref.watch(appSettingsServiceProvider);
var settings = ref.watch(appSettingsServiceProvider);
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
List<RenderAssetGridElement> elements = [];
DateTime? lastDate;
assetGroups.forEach((groupName, assets) {
try {
final date = DateTime.parse(groupName);
if (lastDate == null || lastDate!.month != date.month) {
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
title: groupName,
date: date,
),
);
}
// Add group title
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.dayTitle,
title: groupName,
date: date,
relatedAssetList: assets,
),
);
// Add rows
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
date: date,
assetRow: RenderAssetGridRow(
assets.sublist(cursor, cursor + rowElements),
),
);
elements.add(rowElement);
cursor += rowElements;
}
lastDate = date;
} catch (e) {
debugPrint(e.toString());
}
});
return elements;
return assetGroupsToRenderList(assetGroups, assetsPerRow);
});

View File

@@ -1,92 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:openapi/api.dart';
class HomePageStateNotifier extends StateNotifier<HomePageState> {
final ShareService _shareService;
HomePageStateNotifier(this._shareService)
: super(
HomePageState(
isMultiSelectEnable: false,
selectedItems: {},
selectedDateGroup: {},
),
);
void addSelectedDateGroup(String dateGroupTitle) {
state = state.copyWith(
selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle},
);
}
void removeSelectedDateGroup(String dateGroupTitle) {
var currentDateGroup = state.selectedDateGroup;
currentDateGroup.removeWhere((e) => e == dateGroupTitle);
state = state.copyWith(selectedDateGroup: currentDateGroup);
}
void enableMultiSelect(Set<AssetResponseDto> selectedItems) {
state =
state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems);
}
void disableMultiSelect() {
state = state.copyWith(
isMultiSelectEnable: false,
selectedItems: {},
selectedDateGroup: {},
);
}
void addSingleSelectedItem(AssetResponseDto asset) {
state = state.copyWith(selectedItems: {...state.selectedItems, asset});
}
void addMultipleSelectedItems(List<AssetResponseDto> assets) {
state = state.copyWith(selectedItems: {...state.selectedItems, ...assets});
}
void removeSingleSelectedItem(AssetResponseDto asset) {
Set<AssetResponseDto> currentList = state.selectedItems;
currentList.removeWhere((e) => e.id == asset.id);
state = state.copyWith(selectedItems: currentList);
}
void removeMultipleSelectedItem(List<AssetResponseDto> assets) {
Set<AssetResponseDto> currentList = state.selectedItems;
for (AssetResponseDto asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedItems: currentList);
}
void shareAssets(List<AssetResponseDto> assets, BuildContext context) {
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService
.shareAssets(assets)
.then((_) => Navigator.of(buildContext).pop());
return const ShareDialog();
},
barrierDismissible: false,
);
}
}
final homePageStateProvider =
StateNotifierProvider<HomePageStateNotifier, HomePageState>(
((ref) => HomePageStateNotifier(ref.watch(shareServiceProvider))),
);

View File

@@ -0,0 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
final multiselectProvider = StateProvider((ref) {
return false;
});

View File

@@ -0,0 +1,37 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';
class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
AssetCacheService() : super("asset_cache");
@override
void put(List<AssetResponseDto> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
@override
Future<List<AssetResponseDto>> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = mapList
.map((e) => AssetResponseDto.fromJson(e))
.whereNotNull()
.toList();
return responseData;
} catch (e) {
debugPrint(e.toString());
return [];
}
}
}
final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(),
);

View File

@@ -0,0 +1,103 @@
import 'dart:math';
import 'package:openapi/api.dart';
enum RenderAssetGridElementType {
assetRow,
dayTitle,
monthTitle;
}
class RenderAssetGridRow {
final List<AssetResponseDto> assets;
RenderAssetGridRow(this.assets);
}
class RenderAssetGridElement {
final RenderAssetGridElementType type;
final RenderAssetGridRow? assetRow;
final String? title;
final DateTime date;
final List<AssetResponseDto>? relatedAssetList;
RenderAssetGridElement(
this.type, {
this.assetRow,
this.title,
required this.date,
this.relatedAssetList,
});
}
List<RenderAssetGridElement> assetsToRenderList(
List<AssetResponseDto> assets, int assetsPerRow) {
List<RenderAssetGridElement> elements = [];
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
final date = DateTime.parse(assets[cursor].createdAt);
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
date: date,
assetRow: RenderAssetGridRow(
assets.sublist(cursor, cursor + rowElements),
),
);
elements.add(rowElement);
cursor += rowElements;
}
return elements;
}
List<RenderAssetGridElement> assetGroupsToRenderList(
Map<String, List<AssetResponseDto>> assetGroups, int assetsPerRow) {
List<RenderAssetGridElement> elements = [];
DateTime? lastDate;
assetGroups.forEach((groupName, assets) {
final date = DateTime.parse(groupName);
if (lastDate == null || lastDate!.month != date.month) {
elements.add(
RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
title: groupName, date: date),
);
}
// Add group title
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.dayTitle,
title: groupName,
date: date,
relatedAssetList: assets,
),
);
// Add rows
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
date: date,
assetRow: RenderAssetGridRow(
assets.sublist(cursor, cursor + rowElements),
),
);
elements.add(rowElement);
cursor += rowElements;
}
lastDate = date;
});
return elements;
}

View File

@@ -0,0 +1,72 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
required this.multiselectEnabled,
required this.onSelect,
required this.onDeselect,
required this.selected,
}) : super(key: key);
final String isoDate;
final bool multiselectEnabled;
final Function onSelect;
final Function onDeselect;
final bool selected;
@override
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear
? "daily_title_text_date".tr()
: "daily_title_text_date_year".tr();
var dateText =
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
void handleTitleIconClick() {
if (selected) {
onDeselect();
} else {
onSelect();
}
}
return Padding(
padding: const EdgeInsets.only(
top: 29.0,
bottom: 29.0,
left: 12.0,
right: 12.0,
),
child: Row(
children: [
Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
GestureDetector(
onTap: handleTitleIconClick,
child: multiselectEnabled && selected
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
],
),
);
}
}

View File

@@ -1,40 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({
Key? key,
required this.onPressed,
required this.selectedItemCount,
}) : super(key: key);
final Function onPressed;
final int selectedItemCount;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Positioned(
top: 10,
left: 0,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 46),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
'$selectedItemCount',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
),
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({
Key? key,
required this.onPressed,
required this.selectedItemCount,
}) : super(key: key);
final Function onPressed;
final int selectedItemCount;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 15),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
'$selectedItemCount',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,274 @@
import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:openapi/api.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'daily_title_text.dart';
import 'disable_multi_select_button.dart';
import 'draggable_scrollbar_custom.dart';
typedef ImmichAssetGridSelectionListener = void Function(
bool,
Set<AssetResponseDto>,
);
class ImmichAssetGridState extends State<ImmichAssetGrid> {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
bool _scrolling = false;
final Set<String> _selectedAssets = HashSet();
List<AssetResponseDto> get _assets {
return widget.renderList
.map((e) {
if (e.type == RenderAssetGridElementType.assetRow) {
return e.assetRow!.assets;
} else {
return List<AssetResponseDto>.empty();
}
})
.flattened
.toList();
}
Set<AssetResponseDto> _getSelectedAssets() {
return _selectedAssets
.map((e) => _assets.firstWhereOrNull((a) => a.id == e))
.whereNotNull()
.toSet();
}
void _callSelectionListener(bool selectionActive) {
widget.listener?.call(selectionActive, _getSelectedAssets());
}
void _selectAssets(List<AssetResponseDto> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.add(e.id);
}
_callSelectionListener(true);
});
}
void _deselectAssets(List<AssetResponseDto> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.remove(e.id);
}
_callSelectionListener(_selectedAssets.isNotEmpty);
});
}
void _deselectAll() {
setState(() {
_selectedAssets.clear();
});
_callSelectionListener(false);
}
bool _allAssetsSelected(List<AssetResponseDto> assets) {
return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
}
double _getItemSize(BuildContext context) {
return MediaQuery.of(context).size.width / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
}
Widget _buildThumbnailOrPlaceholder(
AssetResponseDto asset,
bool placeholder,
) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage(
asset: asset,
assetList: _assets,
multiselectEnabled: widget.selectionActive,
isSelected: _selectedAssets.contains(asset.id),
onSelect: () => _selectAssets([asset]),
onDeselect: () => _deselectAssets([asset]),
useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator,
);
}
Widget _buildAssetRow(
BuildContext context,
RenderAssetGridRow row,
bool scrolling,
) {
double size = _getItemSize(context);
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((AssetResponseDto asset) {
bool last = asset == row.assets.last;
return Container(
key: Key("asset-${asset.id}"),
width: size,
height: size,
margin: EdgeInsets.only(
top: widget.margin,
right: last ? 0.0 : widget.margin,
),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
}
Widget _buildTitle(
BuildContext context,
String title,
List<AssetResponseDto> assets,
) {
return DailyTitleText(
isoDate: title,
multiselectEnabled: widget.selectionActive,
onSelect: () => _selectAssets(assets),
onDeselect: () => _deselectAssets(assets),
selected: _allAssetsSelected(assets),
);
}
Widget _buildMonthTitle(BuildContext context, String title) {
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(title));
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.headline1?.color,
),
),
);
}
Widget _itemBuilder(BuildContext c, int position) {
final item = widget.renderList[position];
if (item.type == RenderAssetGridElementType.dayTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, _scrolling);
}
return const Text("Invalid widget type!");
}
Text _labelBuilder(int pos) {
final date = widget.renderList[pos].date;
return Text(
DateFormat.yMMMd().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildMultiSelectIndicator() {
return DisableMultiSelectButton(
onPressed: () => _deselectAll(),
selectedItemCount: _selectedAssets.length,
);
}
Widget _buildAssetGrid() {
final useDragScrolling = _assets.length >= 20;
void dragScrolling(bool active) {
setState(() {
_scrolling = active;
});
}
final listWidget = ScrollablePositionedList.builder(
itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController,
itemCount: widget.renderList.length,
);
if (!useDragScrolling) {
return listWidget;
}
return DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: listWidget,
);
}
@override
void didUpdateWidget(ImmichAssetGrid oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.selectionActive) {
setState(() {
_selectedAssets.clear();
});
}
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
_buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(),
],
);
}
}
class ImmichAssetGrid extends StatefulWidget {
final List<RenderAssetGridElement> renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
const ImmichAssetGrid({
super.key,
required this.renderList,
required this.assetsPerRow,
required this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.selectionActive = false,
});
@override
State<StatefulWidget> createState() {
return ImmichAssetGridState();
}
}

View File

@@ -1,176 +1,172 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset;
final List<AssetResponseDto> assetList;
final bool showStorageIndicator;
final bool useGrayBoxPlaceholder;
const ThumbnailImage({
Key? key,
required this.asset,
required this.assetList,
this.showStorageIndicator = true,
this.useGrayBoxPlaceholder = false,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget buildSelectionIcon(AssetResponseDto asset) {
if (selectedAsset.contains(asset)) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else {
return const Icon(
Icons.circle_outlined,
color: Colors.white,
);
}
}
return GestureDetector(
onTap: () {
if (isMultiSelectEnable &&
selectedAsset.contains(asset) &&
selectedAsset.length == 1) {
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable &&
selectedAsset.contains(asset) &&
selectedAsset.length > 1) {
ref
.watch(homePageStateProvider.notifier)
.removeSingleSelectedItem(asset);
} else if (isMultiSelectEnable && !selectedAsset.contains(asset)) {
ref
.watch(homePageStateProvider.notifier)
.addSingleSelectedItem(asset);
} else {
AutoRouter.of(context).push(
GalleryViewerRoute(
assetList: assetList,
asset: asset,
),
);
}
},
onLongPress: () {
// Enable multi select function
ref.watch(homePageStateProvider.notifier).enableMultiSelect({asset});
HapticFeedback.heavyImpact();
},
child: Hero(
tag: asset.id,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
border: isMultiSelectEnable && selectedAsset.contains(asset)
? Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
)
: const Border(),
),
child: CachedNetworkImage(
cacheKey: 'thumbnail-image-${asset.id}',
width: 300,
height: 300,
memCacheHeight: 200,
maxWidthDiskCache: 200,
maxHeightDiskCache: 200,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
if (useGrayBoxPlaceholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
value: downloadProgress.progress,
),
);
},
errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
),
if (isMultiSelectEnable)
Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: buildSelectionIcon(asset),
),
),
if (showStorageIndicator)
Positioned(
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
color: Colors.white,
size: 18,
),
),
if (asset.type != AssetTypeEnum.IMAGE)
Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
],
),
),
);
}
}
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset;
final List<AssetResponseDto> assetList;
final bool showStorageIndicator;
final bool useGrayBoxPlaceholder;
final bool isSelected;
final bool multiselectEnabled;
final Function? onSelect;
final Function? onDeselect;
const ThumbnailImage({
Key? key,
required this.asset,
required this.assetList,
this.showStorageIndicator = true,
this.useGrayBoxPlaceholder = false,
this.isSelected = false,
this.multiselectEnabled = false,
this.onDeselect,
this.onSelect,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget buildSelectionIcon(AssetResponseDto asset) {
if (isSelected) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else {
return const Icon(
Icons.circle_outlined,
color: Colors.white,
);
}
}
return GestureDetector(
onTap: () {
if (multiselectEnabled) {
if (isSelected) {
onDeselect?.call();
} else {
onSelect?.call();
}
} else {
AutoRouter.of(context).push(
GalleryViewerRoute(
assetList: assetList,
asset: asset,
),
);
}
},
onLongPress: () {
onSelect?.call();
HapticFeedback.heavyImpact();
},
child: Hero(
tag: asset.id,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
border: multiselectEnabled && isSelected
? Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
)
: const Border(),
),
child: CachedNetworkImage(
cacheKey: 'thumbnail-image-${asset.id}',
width: 300,
height: 300,
memCacheHeight: 200,
maxWidthDiskCache: 200,
maxHeightDiskCache: 200,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
if (useGrayBoxPlaceholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
value: downloadProgress.progress,
),
);
},
errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
),
if (multiselectEnabled)
Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: buildSelectionIcon(asset),
),
),
if (showStorageIndicator)
Positioned(
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
color: Colors.white,
size: 18,
),
),
if (asset.type != AssetTypeEnum.IMAGE)
Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
],
),
),
);
}
}

View File

@@ -1,107 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:openapi/api.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
required this.assetGroup,
}) : super(key: key);
final String isoDate;
final List<AssetResponseDto> assetGroup;
@override
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear
? "daily_title_text_date".tr()
: "daily_title_text_date_year".tr();
var dateText =
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
void _handleTitleIconClick() {
if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length == 1 &&
selectedItems.length <= assetGroup.length) {
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) {
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length > 1) {
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.addMultipleSelectedItems(assetGroup);
} else {
ref
.watch(homePageStateProvider.notifier)
.enableMultiSelect(assetGroup.toSet());
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
}
}
return Padding(
padding: const EdgeInsets.only(
top: 29.0,
bottom: 29.0,
left: 12.0,
right: 12.0,
),
child: Row(
children: [
Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
GestureDetector(
onTap: _handleTitleIconClick,
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
],
),
);
}
}

View File

@@ -1,167 +0,0 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart';
import 'package:openapi/api.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../thumbnail_image.dart';
class ImmichAssetGrid extends HookConsumerWidget {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
final List<RenderAssetGridElement> renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
ImmichAssetGrid({
super.key,
required this.renderList,
required this.assetsPerRow,
required this.showStorageIndicator,
this.margin = 5.0,
});
List<AssetResponseDto> get _assets {
return renderList
.map((e) {
if (e.type == RenderAssetGridElementType.assetRow) {
return e.assetRow!.assets;
} else {
return List<AssetResponseDto>.empty();
}
})
.flattened
.toList();
}
double _getItemSize(BuildContext context) {
return MediaQuery.of(context).size.width / assetsPerRow -
margin * (assetsPerRow - 1) / assetsPerRow;
}
Widget _buildThumbnailOrPlaceholder(
AssetResponseDto asset, bool placeholder) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage(
asset: asset,
assetList: _assets,
showStorageIndicator: showStorageIndicator,
useGrayBoxPlaceholder: true,
);
}
Widget _buildAssetRow(
BuildContext context, RenderAssetGridRow row, bool scrolling) {
double size = _getItemSize(context);
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((AssetResponseDto asset) {
bool last = asset == row.assets.last;
return Container(
key: Key("asset-${asset.id}"),
width: size,
height: size,
margin: EdgeInsets.only(top: margin, right: last ? 0.0 : margin),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
}
Widget _buildTitle(
BuildContext context, String title, List<AssetResponseDto> assets) {
return DailyTitleText(
isoDate: title,
assetGroup: assets,
);
}
Widget _buildMonthTitle(BuildContext context, String title) {
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(title));
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.headline1?.color,
),
),
);
}
Widget _itemBuilder(BuildContext c, int position, bool scrolling) {
final item = renderList[position];
if (item.type == RenderAssetGridElementType.dayTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, scrolling);
}
return const Text("Invalid widget type!");
}
Text _labelBuilder(int pos) {
final date = renderList[pos].date;
return Text(DateFormat.yMMMd().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final scrolling = useState(false);
void dragScrolling(bool active) {
scrolling.value = active;
}
Widget itemBuilder(BuildContext c, int position) {
return _itemBuilder(c, position, scrolling.value);
}
return DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: ScrollablePositionedList.builder(
itemBuilder: itemBuilder,
itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController,
itemCount: renderList.length,
));
}
}

View File

@@ -1,11 +1,15 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
class ControlBottomAppBar extends ConsumerWidget {
const ControlBottomAppBar({Key? key}) : super(key: key);
final Function onShare;
final Function onDelete;
const ControlBottomAppBar(
{Key? key, required this.onShare, required this.onDelete})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -36,7 +40,9 @@ class ControlBottomAppBar extends ConsumerWidget {
showDialog(
context: context,
builder: (BuildContext context) {
return const DeleteDialog();
return DeleteDialog(
onDelete: onDelete,
);
},
);
},
@@ -45,14 +51,7 @@ class ControlBottomAppBar extends ConsumerWidget {
iconData: Icons.share,
label: "control_bottom_app_bar_share".tr(),
onPressed: () {
final homePageState = ref.watch(homePageStateProvider);
ref.watch(homePageStateProvider.notifier).shareAssets(
homePageState.selectedItems.toList(),
context,
);
ref
.watch(homePageStateProvider.notifier)
.disableMultiSelect();
onShare();
},
),
],

View File

@@ -1,109 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:openapi/api.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
required this.assetGroup,
}) : super(key: key);
final String isoDate;
final List<AssetResponseDto> assetGroup;
@override
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear
? "daily_title_text_date".tr()
: "daily_title_text_date_year".tr();
var dateText = DateFormat(formatDateTemplate)
.format(DateTime.parse(isoDate).toLocal());
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
void _handleTitleIconClick() {
if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length == 1 &&
selectedItems.length <= assetGroup.length) {
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) {
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length > 1) {
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.addMultipleSelectedItems(assetGroup);
} else {
ref
.watch(homePageStateProvider.notifier)
.enableMultiSelect(assetGroup.toSet());
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
}
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 29.0,
bottom: 29.0,
left: 12.0,
right: 12.0,
),
child: Row(
children: [
Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
GestureDetector(
onTap: _handleTitleIconClick,
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
],
),
),
);
}
}

View File

@@ -1,18 +1,17 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DeleteDialog extends ConsumerWidget {
const DeleteDialog({Key? key}) : super(key: key);
final Function onDelete;
const DeleteDialog({Key? key, required this.onDelete}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final homePageState = ref.watch(homePageStateProvider);
return AlertDialog(
backgroundColor: Colors.grey[200],
// backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: const Text("delete_dialog_title").tr(),
content: const Text("delete_dialog_alert").tr(),
@@ -21,23 +20,25 @@ class DeleteDialog extends ConsumerWidget {
onPressed: () {
Navigator.of(context).pop();
},
child: const Text(
child: Text(
"delete_dialog_cancel",
style: TextStyle(color: Colors.blueGrey),
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
).tr(),
),
TextButton(
onPressed: () {
ref
.watch(assetProvider.notifier)
.deleteAssets(homePageState.selectedItems);
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
onDelete();
Navigator.of(context).pop();
},
child: Text(
"delete_dialog_ok",
style: TextStyle(color: Colors.red[400]),
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
),
).tr(),
),
],

View File

@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
import 'package:openapi/api.dart';
// ignore: must_be_immutable
class ImageGrid extends ConsumerWidget {
final List<AssetResponseDto> assetGroup;
final List<AssetResponseDto> sortedAssetGroup;
final int tilesPerRow;
final bool showStorageIndicator;
ImageGrid({
Key? key,
required this.assetGroup,
required this.sortedAssetGroup,
this.tilesPerRow = 4,
this.showStorageIndicator = true,
}) : super(key: key);
List<AssetResponseDto> imageSortedList = [];
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: tilesPerRow,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var assetType = assetGroup[index].type;
return GestureDetector(
onTap: () {},
child: ThumbnailImage(
asset: assetGroup[index],
assetList: sortedAssetGroup,
showStorageIndicator: showStorageIndicator,
),
);
},
childCount: assetGroup.length,
),
);
}
}

View File

@@ -2,22 +2,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.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:immich_mobile/shared/services/share.service.dart';
import 'package:openapi/api.dart';
class HomePage extends HookConsumerWidget {
@@ -26,22 +21,9 @@ class HomePage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
var renderList = ref.watch(renderListProvider);
ScrollController scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
List<Widget> imageGridGroup = [];
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var homePageState = ref.watch(homePageStateProvider);
List<AssetResponseDto> sortedAssetList = [];
// set sorted List
for (var group in assetGroupByDateTime.values) {
for (var value in group) {
sortedAssetList.add(value);
}
}
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selection = useState(<AssetResponseDto>{});
useEffect(
() {
@@ -57,115 +39,61 @@ class HomePage extends HookConsumerWidget {
ref.read(assetProvider.notifier).getAllAsset();
}
_buildSelectedItemCountIndicator() {
return DisableMultiSelectButton(
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
selectedItemCount: homePageState.selectedItems.length,
);
}
Widget _buildBody() {
if (assetGroupByDateTime.isNotEmpty) {
int? lastMonth;
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
try {
DateTime parseDateGroup = DateTime.parse(dateGroup);
int currentMonth = parseDateGroup.month;
if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) {
imageGridGroup.add(
MonthlyTitleText(
isoDate: dateGroup,
),
);
}
}
imageGridGroup.add(
DailyTitleText(
key: Key('${dateGroup.toString()}title'),
isoDate: dateGroup,
assetGroup: immichAssetList,
),
);
imageGridGroup.add(
ImageGrid(
assetGroup: immichAssetList,
sortedAssetGroup: sortedAssetList,
tilesPerRow:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
),
);
lastMonth = currentMonth;
} catch (e) {
debugPrint(
"[ERROR] Cannot parse $dateGroup - Wrong create date format : ${immichAssetList.map((asset) => asset.createdAt).toList()}",
);
}
});
Widget buildBody() {
void selectionListener(
bool multiselect,
Set<AssetResponseDto> selectedAssets,
) {
multiselectEnabled.state = multiselect;
selection.value = selectedAssets;
}
_buildSliverAppBar() {
return isMultiSelectEnable
? const SliverToBoxAdapter(
child: SizedBox(
height: 70,
child: null,
),
)
: ImmichSliverAppBar(
onPopBack: reloadAllAsset,
);
void onShareAssets() {
ref.watch(shareServiceProvider).shareAssets(selection.value.toList());
multiselectEnabled.state = false;
}
_buildAssetGrid() {
if (appSettingService
.getSetting(AppSettingsEnum.useExperimentalAssetGrid)) {
return ImmichAssetGrid(
renderList: renderList,
assetsPerRow:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
);
} else {
return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [
...imageGridGroup,
],
),
);
}
void onDelete() {
ref.watch(assetProvider.notifier).deleteAssets(selection.value);
multiselectEnabled.state = false;
}
return SafeArea(
bottom: !isMultiSelectEnable,
top: !isMultiSelectEnable,
bottom: !multiselectEnabled.state,
top: !multiselectEnabled.state,
child: Stack(
children: [
CustomScrollView(
slivers: [
_buildSliverAppBar(),
multiselectEnabled.state
? const SliverToBoxAdapter(
child: SizedBox(
height: 70,
child: null,
),
)
: ImmichSliverAppBar(
onPopBack: reloadAllAsset,
),
],
),
Padding(
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
child: _buildAssetGrid(),
child: ImmichAssetGrid(
renderList: renderList,
assetsPerRow:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: multiselectEnabled.state,
),
),
if (isMultiSelectEnable) ...[
_buildSelectedItemCountIndicator(),
const ControlBottomAppBar(),
if (multiselectEnabled.state) ...[
ControlBottomAppBar(
onShare: onShareAssets,
onDelete: onDelete,
),
],
],
),
@@ -174,7 +102,7 @@ class HomePage extends HookConsumerWidget {
return Scaffold(
drawer: const ProfileDrawer(),
body: _buildBody(),
body: buildBody(),
);
}
}

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.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/backup/services/backup.service.dart';
@@ -15,6 +18,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
this._deviceInfoService,
this._backupService,
this._apiService,
this._assetCacheService,
this._albumCacheService,
this._sharedAlbumCacheService,
) : super(
AuthenticationState(
deviceId: "",
@@ -41,6 +47,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final DeviceInfoService _deviceInfoService;
final BackupService _backupService;
final ApiService _apiService;
final AssetCacheService _assetCacheService;
final AlbumCacheService _albumCacheService;
final SharedAlbumCacheService _sharedAlbumCacheService;
Future<bool> login(
String email,
@@ -120,6 +129,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
.delete(savedLoginInfoKey);
}
} catch (e) {
HapticFeedback.vibrate();
debugPrint("Error logging in $e");
return false;
}
@@ -151,7 +161,23 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> logout() async {
Hive.box(userInfoBox).delete(accessTokenKey);
state = state.copyWith(isAuthenticated: false);
_assetCacheService.invalidate();
_albumCacheService.invalidate();
_sharedAlbumCacheService.invalidate();
// Remove login info from local storage
var loginInfo =
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
if (loginInfo != null) {
loginInfo.email = "";
loginInfo.password = "";
loginInfo.isSaveLogin = false;
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
loginInfo,
);
}
return true;
}
@@ -197,5 +223,8 @@ final authenticationProvider =
ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(apiServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(albumCacheServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});

View File

@@ -142,8 +142,8 @@ class ChangePasswordButton extends ConsumerWidget {
return ElevatedButton(
style: ElevatedButton.styleFrom(
visualDensity: VisualDensity.standard,
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.grey[50],
elevation: 2,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
),

View File

@@ -203,8 +203,8 @@ class LoginButton extends ConsumerWidget {
return ElevatedButton(
style: ElevatedButton.styleFrom(
visualDensity: VisualDensity.standard,
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.grey[50],
elevation: 2,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
),
@@ -228,7 +228,7 @@ class LoginButton extends ConsumerWidget {
AutoRouter.of(context).push(const ChangePasswordRoute());
} else {
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/tab-controller-page");
AutoRouter.of(context).replace(const TabControllerRoute());
}
} else {
ImmichToast.show(

View File

@@ -1,8 +1,11 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/search/models/search_result_page_state.model.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:intl/intl.dart';
import 'package:openapi/api.dart';
@@ -66,3 +69,12 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
.format(DateTime.parse(element.createdAt).toLocal()),
);
});
final searchRenderListProvider = StateProvider((ref) {
var assetGroups = ref.watch(searchResultGroupByDateTimeProvider);
var settings = ref.watch(appSettingsServiceProvider);
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
return assetGroupsToRenderList(assetGroups, assetsPerRow);
});

View File

@@ -4,14 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class SearchResultPage extends HookConsumerWidget {
const SearchResultPage({Key? key, required this.searchTerm})
@@ -21,17 +19,12 @@ class SearchResultPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
ScrollController scrollController = useScrollController();
final searchTermController = useTextEditingController(text: "");
final isNewSearch = useState(false);
final currentSearchTerm = useState(searchTerm);
final List<Widget> imageGridGroup = [];
FocusNode? searchFocusNode;
List<AssetResponseDto> sortedAssetList = [];
useEffect(
() {
searchFocusNode = FocusNode();
@@ -117,7 +110,12 @@ class SearchResultPage extends HookConsumerWidget {
_buildSearchResult() {
var searchResultPageState = ref.watch(searchResultPageProvider);
var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider);
var searchResultRenderList = ref.watch(searchRenderListProvider);
var settings = ref.watch(appSettingsServiceProvider);
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
final showStorageIndicator =
settings.getSetting(AppSettingsEnum.storageIndicator);
if (searchResultPageState.isError) {
return const Text("Error");
@@ -132,57 +130,11 @@ class SearchResultPage extends HookConsumerWidget {
}
if (searchResultPageState.isSuccess) {
if (searchResultPageState.searchResult.isNotEmpty) {
int? lastMonth;
// set sorted List
for (var group in assetGroupByDateTime.values) {
for (var value in group) {
sortedAssetList.add(value);
}
}
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
DateTime parseDateGroup = DateTime.parse(dateGroup);
int currentMonth = parseDateGroup.month;
if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) {
imageGridGroup.add(
MonthlyTitleText(
isoDate: dateGroup,
),
);
}
}
imageGridGroup.add(
DailyTitleText(
isoDate: dateGroup,
assetGroup: immichAssetList,
),
);
imageGridGroup.add(
ImageGrid(
assetGroup: immichAssetList,
sortedAssetGroup: sortedAssetList,
),
);
lastMonth = currentMonth;
});
return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [...imageGridGroup],
),
);
} else {
return const Text("No assets found");
}
return ImmichAssetGrid(
renderList: searchResultRenderList,
assetsPerRow: assetsPerRow,
showStorageIndicator: showStorageIndicator,
);
}
return const SizedBox();

View File

@@ -1,11 +1,6 @@
import 'package:easy_localization/easy_localization.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/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class ExperimentalSettings extends HookConsumerWidget {
const ExperimentalSettings({
@@ -14,33 +9,6 @@ class ExperimentalSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final useExperimentalAssetGrid = useState(false);
useEffect(
() {
useExperimentalAssetGrid.value = appSettingService
.getSetting(AppSettingsEnum.useExperimentalAssetGrid);
return null;
},
[],
);
void changeUseExperimentalAssetGrid(bool status) {
useExperimentalAssetGrid.value = status;
appSettingService.setSetting(
AppSettingsEnum.useExperimentalAssetGrid,
status,
);
ImmichToast.show(
context: context,
msg: "settings_require_restart".tr(),
gravity: ToastGravity.BOTTOM,
);
}
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
title: const Text(
@@ -55,25 +23,25 @@ class ExperimentalSettings extends HookConsumerWidget {
fontSize: 13,
),
).tr(),
children: [
SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
"experimental_settings_new_asset_list_title",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
"experimental_settings_new_asset_list_subtitle",
style: TextStyle(
fontSize: 12,
),
).tr(),
value: useExperimentalAssetGrid.value,
onChanged: changeUseExperimentalAssetGrid,
),
children: const [
// SwitchListTile.adaptive(
// activeColor: Theme.of(context).primaryColor,
// title: const Text(
// "experimental_settings_new_asset_list_title",
// style: TextStyle(
// fontSize: 12,
// fontWeight: FontWeight.bold,
// ),
// ).tr(),
// subtitle: const Text(
// "experimental_settings_new_asset_list_subtitle",
// style: TextStyle(
// fontSize: 12,
// ),
// ).tr(),
// value: useExperimentalAssetGrid.value,
// onChanged: changeUseExperimentalAssetGrid,
// ),
],
);
}

View File

@@ -43,7 +43,7 @@ class SettingsPage extends HookConsumerWidget {
const ThemeSetting(),
const AssetListSettings(),
if (Platform.isAndroid) const NotificationSetting(),
const ExperimentalSettings(),
//const ExperimentalSettings(),
],
).toList(),
],

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
@@ -9,24 +10,50 @@ import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final DeviceInfoService _deviceInfoService = DeviceInfoService();
AssetNotifier(this._assetService) : super([]);
AssetNotifier(this._assetService, this._assetCacheService) : super([]);
_cacheState() {
_assetCacheService.put(state);
}
getAllAsset() async {
final stopwatch = Stopwatch();
if (await _assetCacheService.isValid() && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
stopwatch.start();
var allAssets = await _assetService.getAllAsset();
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (allAssets != null) {
state = allAssets;
stopwatch.start();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
}
clearAllAsset() {
state = [];
_cacheState();
}
onNewAssetUploaded(AssetResponseDto newAsset) {
state = [...state, newAsset];
_cacheState();
}
deleteAssets(Set<AssetResponseDto> deleteAssets) async {
@@ -65,12 +92,15 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
state.where((immichAsset) => immichAsset.id != asset.id).toList();
}
}
_cacheState();
}
}
final assetProvider =
StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) {
return AssetNotifier(ref.watch(assetServiceProvider));
return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
});
final assetGroupByDateTimeProvider = StateProvider((ref) {

View File

@@ -0,0 +1,49 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
abstract class JsonCache<T> {
final String cacheFileName;
JsonCache(this.cacheFileName);
Future<File> _getCacheFile() async {
final basePath = await getTemporaryDirectory();
final basePathName = basePath.path;
final file = File("$basePathName/$cacheFileName.bin");
return file;
}
Future<bool> isValid() async {
final file = await _getCacheFile();
return await file.exists();
}
Future<void> invalidate() async {
final file = await _getCacheFile();
await file.delete();
}
Future<void> putRawData(dynamic data) async {
final jsonString = json.encode(data);
final file = await _getCacheFile();
if (!await file.exists()) {
await file.create();
}
await file.writeAsString(jsonString);
}
dynamic readRawData() async {
final file = await _getCacheFile();
final data = await file.readAsString();
return json.decode(data);
}
void put(T data);
Future<T> get();
}

View File

@@ -29,9 +29,9 @@ class SplashScreenPage extends HookConsumerWidget {
if (isAuthenticated) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/tab-controller-page");
AutoRouter.of(context).replace(const TabControllerRoute());
} else {
AutoRouter.of(context).push(const LoginRoute());
AutoRouter.of(context).replace(const LoginRoute());
}
}
@@ -40,7 +40,7 @@ class SplashScreenPage extends HookConsumerWidget {
if (loginInfo?.isSaveLogin == true) {
performLoggingIn();
} else {
AutoRouter.of(context).push(const LoginRoute());
AutoRouter.of(context).replace(const LoginRoute());
}
return null;
},

View File

@@ -1,8 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class TabControllerPage extends ConsumerWidget {
@@ -10,9 +11,7 @@ class TabControllerPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter(
routes: [
const HomeRoute(),
@@ -22,17 +21,23 @@ class TabControllerPage extends ConsumerWidget {
],
builder: (context, child, animation) {
final tabsRouter = AutoTabsRouter.of(context);
final appRouter = AutoRouter.of(context);
return WillPopScope(
onWillPop: () async {
tabsRouter.setActiveIndex(0);
return false;
bool atHomeTab = tabsRouter.activeIndex == 0;
if (!atHomeTab) {
tabsRouter.setActiveIndex(0);
} else {
appRouter.navigateBack();
}
return atHomeTab;
},
child: Scaffold(
body: FadeTransition(
opacity: animation,
child: child,
),
bottomNavigationBar: isMultiSelectEnable
bottomNavigationBar: multiselectEnabled
? null
: BottomNavigationBar(
selectedLabelStyle: const TextStyle(
@@ -45,6 +50,7 @@ class TabControllerPage extends ConsumerWidget {
),
currentIndex: tabsRouter.activeIndex,
onTap: (index) {
HapticFeedback.selectionClick();
tabsRouter.setActiveIndex(index);
},
items: [

View File

@@ -103,8 +103,8 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
visualDensity: VisualDensity.standard,
primary: Colors.indigo,
onPrimary: Colors.grey[50],
backgroundColor: Colors.indigo,
foregroundColor: Colors.grey[50],
elevation: 2,
padding: const EdgeInsets.symmetric(
vertical: 10,

View File

@@ -25,9 +25,11 @@ String getImageUrl(final AssetResponseDto asset) {
return '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false';
}
String _getThumbnailUrl(final String id,
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
String _getThumbnailUrl(
final String id, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
final box = Hive.box(userInfoBox);
return '${box.get(serverEndpointKey)}/asset/thumbnail/${id}?format=${type.value}';
return '${box.get(serverEndpointKey)}/asset/thumbnail/$id?format=${type.value}';
}

View File

@@ -72,8 +72,8 @@ ThemeData immichDarkTheme = ThemeData(
cardColor: Colors.grey[900],
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
onPrimary: Colors.black87,
primary: immichDarkThemePrimaryColor,
foregroundColor: Colors.black87,
backgroundColor: immichDarkThemePrimaryColor,
),
),
);
@@ -126,8 +126,8 @@ ThemeData immichLightTheme = ThemeData(
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: Colors.indigo,
onPrimary: Colors.white,
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
),
);

View File

@@ -112,8 +112,10 @@ class ImmichCacheInfoRepository extends ImmichCacheRepository {
}
@override
Future<CacheObject> insert(CacheObject cacheObject,
{bool setTouchedToNow = true}) async {
Future<CacheObject> insert(
CacheObject cacheObject, {
bool setTouchedToNow = true,
}) async {
int newId = keyLookupHiveBox.length == 0
? 0
: keyLookupHiveBox.values.reduce(max) + 1;
@@ -144,8 +146,10 @@ class ImmichCacheInfoRepository extends ImmichCacheRepository {
}
@override
Future<int> update(CacheObject cacheObject,
{bool setTouchedToNow = true}) async {
Future<int> update(
CacheObject cacheObject, {
bool setTouchedToNow = true,
}) async {
if (cacheObject.id != null) {
cacheObjectLookupBox.put(cacheObject.id, cacheObject.toMap());
return 1;

View File

@@ -19,6 +19,8 @@ doc/AssetTypeEnum.md
doc/AuthenticationApi.md
doc/CheckDuplicateAssetDto.md
doc/CheckDuplicateAssetResponseDto.md
doc/CheckExistingAssetsDto.md
doc/CheckExistingAssetsResponseDto.md
doc/CreateAlbumDto.md
doc/CreateDeviceInfoDto.md
doc/CreateProfileImageResponseDto.md
@@ -48,6 +50,7 @@ doc/SearchAssetDto.md
doc/ServerInfoApi.md
doc/ServerInfoResponseDto.md
doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md
doc/ServerVersionReponseDto.md
doc/SignUpDto.md
doc/SmartInfoResponseDto.md
@@ -56,6 +59,7 @@ doc/TimeGroupEnum.md
doc/UpdateAlbumDto.md
doc/UpdateDeviceInfoDto.md
doc/UpdateUserDto.md
doc/UsageByUserDto.md
doc/UserApi.md
doc/UserCountResponseDto.md
doc/UserResponseDto.md
@@ -91,6 +95,8 @@ lib/model/asset_response_dto.dart
lib/model/asset_type_enum.dart
lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_dto.dart
lib/model/check_existing_assets_dto.dart
lib/model/check_existing_assets_response_dto.dart
lib/model/create_album_dto.dart
lib/model/create_device_info_dto.dart
lib/model/create_profile_image_response_dto.dart
@@ -117,6 +123,7 @@ lib/model/remove_assets_dto.dart
lib/model/search_asset_dto.dart
lib/model/server_info_response_dto.dart
lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart
lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart
@@ -125,7 +132,10 @@ lib/model/time_group_enum.dart
lib/model/update_album_dto.dart
lib/model/update_device_info_dto.dart
lib/model/update_user_dto.dart
lib/model/usage_by_user_dto.dart
lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart
pubspec.yaml
test/check_existing_assets_dto_test.dart
test/check_existing_assets_response_dto_test.dart

View File

@@ -76,6 +76,7 @@ Class | Method | HTTP request | Description
*AlbumApi* | [**removeUserFromAlbum**](doc//AlbumApi.md#removeuserfromalbum) | **DELETE** /album/{albumId}/user/{userId} |
*AlbumApi* | [**updateAlbumInfo**](doc//AlbumApi.md#updatealbuminfo) | **PATCH** /album/{albumId} |
*AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check |
*AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist |
*AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset |
*AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **GET** /asset/download |
*AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset |
@@ -102,6 +103,7 @@ Class | Method | HTTP request | Description
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image |
*UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user |
@@ -129,6 +131,8 @@ Class | Method | HTTP request | Description
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
- [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
- [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
- [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateDeviceInfoDto](doc//CreateDeviceInfoDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
@@ -155,6 +159,7 @@ Class | Method | HTTP request | Description
- [SearchAssetDto](doc//SearchAssetDto.md)
- [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
- [ServerPingResponse](doc//ServerPingResponse.md)
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
- [SignUpDto](doc//SignUpDto.md)
- [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
@@ -163,6 +168,7 @@ Class | Method | HTTP request | Description
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserCountResponseDto](doc//UserCountResponseDto.md)
- [UserResponseDto](doc//UserResponseDto.md)
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)

View File

@@ -10,6 +10,7 @@ All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check |
[**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist |
[**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset |
[**downloadFile**](AssetApi.md#downloadfile) | **GET** /asset/download |
[**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset |
@@ -76,6 +77,55 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **checkExistingAssets**
> CheckExistingAssetsResponseDto checkExistingAssets(checkExistingAssetsDto)
Checks if multiple assets exist on the server and returns all existing - used by background backup
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final checkExistingAssetsDto = CheckExistingAssetsDto(); // CheckExistingAssetsDto |
try {
final result = api_instance.checkExistingAssets(checkExistingAssetsDto);
print(result);
} catch (e) {
print('Exception when calling AssetApi->checkExistingAssets: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**checkExistingAssetsDto** | [**CheckExistingAssetsDto**](CheckExistingAssetsDto.md)| |
### Return type
[**CheckExistingAssetsResponseDto**](CheckExistingAssetsResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **deleteAsset**
> List<DeleteAssetResponseDto> deleteAsset(deleteAssetDto)

View File

@@ -0,0 +1,16 @@
# openapi.model.AssetCountResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**photos** | **int** | |
**videos** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,16 @@
# openapi.model.CheckExistingAssetsDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**deviceAssetIds** | **List<String>** | | [default to const []]
**deviceId** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,15 @@
# openapi.model.CheckExistingAssetsResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**existingIds** | **List<String>** | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -11,6 +11,7 @@ Method | HTTP request | Description
------------- | ------------- | -------------
[**getServerInfo**](ServerInfoApi.md#getserverinfo) | **GET** /server-info |
[**getServerVersion**](ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
[**getStats**](ServerInfoApi.md#getstats) | **GET** /server-info/stats |
[**pingServer**](ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
@@ -88,6 +89,43 @@ No authorization required
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getStats**
> ServerStatsResponseDto getStats()
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = ServerInfoApi();
try {
final result = api_instance.getStats();
print(result);
} catch (e) {
print('Exception when calling ServerInfoApi->getStats: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**ServerStatsResponseDto**](ServerStatsResponseDto.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **pingServer**
> ServerPingResponse pingServer()

View File

@@ -0,0 +1,20 @@
# openapi.model.ServerStatsResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**photos** | **int** | |
**videos** | **int** | |
**objects** | **int** | |
**usageRaw** | **int** | |
**usage** | **String** | |
**usageByUser** | [**List<UsageByUserDto>**](UsageByUserDto.md) | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,20 @@
# openapi.model.UsageByUserDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**userId** | **String** | |
**objects** | **int** | |
**videos** | **int** | |
**photos** | **int** | |
**usageRaw** | **int** | |
**usage** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -49,6 +49,8 @@ part 'model/asset_response_dto.dart';
part 'model/asset_type_enum.dart';
part 'model/check_duplicate_asset_dto.dart';
part 'model/check_duplicate_asset_response_dto.dart';
part 'model/check_existing_assets_dto.dart';
part 'model/check_existing_assets_response_dto.dart';
part 'model/create_album_dto.dart';
part 'model/create_device_info_dto.dart';
part 'model/create_profile_image_response_dto.dart';
@@ -75,6 +77,7 @@ part 'model/remove_assets_dto.dart';
part 'model/search_asset_dto.dart';
part 'model/server_info_response_dto.dart';
part 'model/server_ping_response.dart';
part 'model/server_stats_response_dto.dart';
part 'model/server_version_reponse_dto.dart';
part 'model/sign_up_dto.dart';
part 'model/smart_info_response_dto.dart';
@@ -83,6 +86,7 @@ part 'model/time_group_enum.dart';
part 'model/update_album_dto.dart';
part 'model/update_device_info_dto.dart';
part 'model/update_user_dto.dart';
part 'model/usage_by_user_dto.dart';
part 'model/user_count_response_dto.dart';
part 'model/user_response_dto.dart';
part 'model/validate_access_token_response_dto.dart';

View File

@@ -72,6 +72,62 @@ class AssetApi {
return null;
}
///
///
/// Checks if multiple assets exist on the server and returns all existing - used by background backup
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
Future<Response> checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/exist';
// ignore: prefer_final_locals
Object? postBody = checkExistingAssetsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
///
///
/// Checks if multiple assets exist on the server and returns all existing - used by background backup
///
/// Parameters:
///
/// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
Future<CheckExistingAssetsResponseDto?> checkExistingAssets(CheckExistingAssetsDto checkExistingAssetsDto,) async {
final response = await checkExistingAssetsWithHttpInfo(checkExistingAssetsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'CheckExistingAssetsResponseDto',) as CheckExistingAssetsResponseDto;
}
return null;
}
/// Performs an HTTP 'DELETE /asset' operation and returns the [Response].
/// Parameters:
///

View File

@@ -98,6 +98,47 @@ class ServerInfoApi {
return null;
}
/// Performs an HTTP 'GET /server-info/stats' operation and returns the [Response].
Future<Response> getStatsWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/server-info/stats';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<ServerStatsResponseDto?> getStats() async {
final response = await getStatsWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStatsResponseDto',) as ServerStatsResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /server-info/ping' operation and returns the [Response].
Future<Response> pingServerWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@@ -220,6 +220,10 @@ class ApiClient {
return CheckDuplicateAssetDto.fromJson(value);
case 'CheckDuplicateAssetResponseDto':
return CheckDuplicateAssetResponseDto.fromJson(value);
case 'CheckExistingAssetsDto':
return CheckExistingAssetsDto.fromJson(value);
case 'CheckExistingAssetsResponseDto':
return CheckExistingAssetsResponseDto.fromJson(value);
case 'CreateAlbumDto':
return CreateAlbumDto.fromJson(value);
case 'CreateDeviceInfoDto':
@@ -272,6 +276,8 @@ class ApiClient {
return ServerInfoResponseDto.fromJson(value);
case 'ServerPingResponse':
return ServerPingResponse.fromJson(value);
case 'ServerStatsResponseDto':
return ServerStatsResponseDto.fromJson(value);
case 'ServerVersionReponseDto':
return ServerVersionReponseDto.fromJson(value);
case 'SignUpDto':
@@ -288,6 +294,8 @@ class ApiClient {
return UpdateDeviceInfoDto.fromJson(value);
case 'UpdateUserDto':
return UpdateUserDto.fromJson(value);
case 'UsageByUserDto':
return UsageByUserDto.fromJson(value);
case 'UserCountResponseDto':
return UserCountResponseDto.fromJson(value);
case 'UserResponseDto':

View File

@@ -0,0 +1,119 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetCountResponseDto {
/// Returns a new [AssetCountResponseDto] instance.
AssetCountResponseDto({
required this.photos,
required this.videos,
});
int photos;
int videos;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetCountResponseDto &&
other.photos == photos &&
other.videos == videos;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(photos.hashCode) +
(videos.hashCode);
@override
String toString() => 'AssetCountResponseDto[photos=$photos, videos=$videos]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'photos'] = photos;
_json[r'videos'] = videos;
return _json;
}
/// Returns a new [AssetCountResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetCountResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AssetCountResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetCountResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AssetCountResponseDto(
photos: mapValueOfType<int>(json, r'photos')!,
videos: mapValueOfType<int>(json, r'videos')!,
);
}
return null;
}
static List<AssetCountResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetCountResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetCountResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetCountResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetCountResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetCountResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetCountResponseDto-objects as value to a dart map
static Map<String, List<AssetCountResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetCountResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetCountResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'photos',
'videos',
};
}

View File

@@ -0,0 +1,121 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class CheckExistingAssetsDto {
/// Returns a new [CheckExistingAssetsDto] instance.
CheckExistingAssetsDto({
this.deviceAssetIds = const [],
required this.deviceId,
});
List<String> deviceAssetIds;
String deviceId;
@override
bool operator ==(Object other) => identical(this, other) || other is CheckExistingAssetsDto &&
other.deviceAssetIds == deviceAssetIds &&
other.deviceId == deviceId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(deviceAssetIds.hashCode) +
(deviceId.hashCode);
@override
String toString() => 'CheckExistingAssetsDto[deviceAssetIds=$deviceAssetIds, deviceId=$deviceId]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'deviceAssetIds'] = deviceAssetIds;
_json[r'deviceId'] = deviceId;
return _json;
}
/// Returns a new [CheckExistingAssetsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CheckExistingAssetsDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "CheckExistingAssetsDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "CheckExistingAssetsDto[$key]" has a null value in JSON.');
});
return true;
}());
return CheckExistingAssetsDto(
deviceAssetIds: json[r'deviceAssetIds'] is List
? (json[r'deviceAssetIds'] as List).cast<String>()
: const [],
deviceId: mapValueOfType<String>(json, r'deviceId')!,
);
}
return null;
}
static List<CheckExistingAssetsDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <CheckExistingAssetsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CheckExistingAssetsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CheckExistingAssetsDto> mapFromJson(dynamic json) {
final map = <String, CheckExistingAssetsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CheckExistingAssetsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CheckExistingAssetsDto-objects as value to a dart map
static Map<String, List<CheckExistingAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CheckExistingAssetsDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CheckExistingAssetsDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'deviceAssetIds',
'deviceId',
};
}

View File

@@ -0,0 +1,113 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class CheckExistingAssetsResponseDto {
/// Returns a new [CheckExistingAssetsResponseDto] instance.
CheckExistingAssetsResponseDto({
this.existingIds = const [],
});
List<String> existingIds;
@override
bool operator ==(Object other) => identical(this, other) || other is CheckExistingAssetsResponseDto &&
other.existingIds == existingIds;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(existingIds.hashCode);
@override
String toString() => 'CheckExistingAssetsResponseDto[existingIds=$existingIds]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'existingIds'] = existingIds;
return _json;
}
/// Returns a new [CheckExistingAssetsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CheckExistingAssetsResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "CheckExistingAssetsResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "CheckExistingAssetsResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return CheckExistingAssetsResponseDto(
existingIds: json[r'existingIds'] is List
? (json[r'existingIds'] as List).cast<String>()
: const [],
);
}
return null;
}
static List<CheckExistingAssetsResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <CheckExistingAssetsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CheckExistingAssetsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CheckExistingAssetsResponseDto> mapFromJson(dynamic json) {
final map = <String, CheckExistingAssetsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CheckExistingAssetsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CheckExistingAssetsResponseDto-objects as value to a dart map
static Map<String, List<CheckExistingAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CheckExistingAssetsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CheckExistingAssetsResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'existingIds',
};
}

View File

@@ -0,0 +1,151 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ServerStatsResponseDto {
/// Returns a new [ServerStatsResponseDto] instance.
ServerStatsResponseDto({
required this.photos,
required this.videos,
required this.objects,
required this.usageRaw,
required this.usage,
this.usageByUser = const [],
});
int photos;
int videos;
int objects;
int usageRaw;
String usage;
List<UsageByUserDto> usageByUser;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerStatsResponseDto &&
other.photos == photos &&
other.videos == videos &&
other.objects == objects &&
other.usageRaw == usageRaw &&
other.usage == usage &&
other.usageByUser == usageByUser;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(photos.hashCode) +
(videos.hashCode) +
(objects.hashCode) +
(usageRaw.hashCode) +
(usage.hashCode) +
(usageByUser.hashCode);
@override
String toString() => 'ServerStatsResponseDto[photos=$photos, videos=$videos, objects=$objects, usageRaw=$usageRaw, usage=$usage, usageByUser=$usageByUser]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'photos'] = photos;
_json[r'videos'] = videos;
_json[r'objects'] = objects;
_json[r'usageRaw'] = usageRaw;
_json[r'usage'] = usage;
_json[r'usageByUser'] = usageByUser;
return _json;
}
/// Returns a new [ServerStatsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ServerStatsResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "ServerStatsResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "ServerStatsResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return ServerStatsResponseDto(
photos: mapValueOfType<int>(json, r'photos')!,
videos: mapValueOfType<int>(json, r'videos')!,
objects: mapValueOfType<int>(json, r'objects')!,
usageRaw: mapValueOfType<int>(json, r'usageRaw')!,
usage: mapValueOfType<String>(json, r'usage')!,
usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser'])!,
);
}
return null;
}
static List<ServerStatsResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <ServerStatsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ServerStatsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ServerStatsResponseDto> mapFromJson(dynamic json) {
final map = <String, ServerStatsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ServerStatsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ServerStatsResponseDto-objects as value to a dart map
static Map<String, List<ServerStatsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ServerStatsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ServerStatsResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'photos',
'videos',
'objects',
'usageRaw',
'usage',
'usageByUser',
};
}

View File

@@ -0,0 +1,151 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UsageByUserDto {
/// Returns a new [UsageByUserDto] instance.
UsageByUserDto({
required this.userId,
required this.objects,
required this.videos,
required this.photos,
required this.usageRaw,
required this.usage,
});
String userId;
int objects;
int videos;
int photos;
int usageRaw;
String usage;
@override
bool operator ==(Object other) => identical(this, other) || other is UsageByUserDto &&
other.userId == userId &&
other.objects == objects &&
other.videos == videos &&
other.photos == photos &&
other.usageRaw == usageRaw &&
other.usage == usage;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(userId.hashCode) +
(objects.hashCode) +
(videos.hashCode) +
(photos.hashCode) +
(usageRaw.hashCode) +
(usage.hashCode);
@override
String toString() => 'UsageByUserDto[userId=$userId, objects=$objects, videos=$videos, photos=$photos, usageRaw=$usageRaw, usage=$usage]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'userId'] = userId;
_json[r'objects'] = objects;
_json[r'videos'] = videos;
_json[r'photos'] = photos;
_json[r'usageRaw'] = usageRaw;
_json[r'usage'] = usage;
return _json;
}
/// Returns a new [UsageByUserDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UsageByUserDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "UsageByUserDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "UsageByUserDto[$key]" has a null value in JSON.');
});
return true;
}());
return UsageByUserDto(
userId: mapValueOfType<String>(json, r'userId')!,
objects: mapValueOfType<int>(json, r'objects')!,
videos: mapValueOfType<int>(json, r'videos')!,
photos: mapValueOfType<int>(json, r'photos')!,
usageRaw: mapValueOfType<int>(json, r'usageRaw')!,
usage: mapValueOfType<String>(json, r'usage')!,
);
}
return null;
}
static List<UsageByUserDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <UsageByUserDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UsageByUserDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UsageByUserDto> mapFromJson(dynamic json) {
final map = <String, UsageByUserDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UsageByUserDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UsageByUserDto-objects as value to a dart map
static Map<String, List<UsageByUserDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UsageByUserDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UsageByUserDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'userId',
'objects',
'videos',
'photos',
'usageRaw',
'usage',
};
}

View File

@@ -0,0 +1,32 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AssetCountResponseDto
void main() {
// final instance = AssetCountResponseDto();
group('test AssetCountResponseDto', () {
// int photos
test('to test the property `photos`', () async {
// TODO
});
// int videos
test('to test the property `videos`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,32 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for CheckExistingAssetsDto
void main() {
// final instance = CheckExistingAssetsDto();
group('test CheckExistingAssetsDto', () {
// List<String> deviceAssetIds (default value: const [])
test('to test the property `deviceAssetIds`', () async {
// TODO
});
// String deviceId
test('to test the property `deviceId`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for CheckExistingAssetsResponseDto
void main() {
// final instance = CheckExistingAssetsResponseDto();
group('test CheckExistingAssetsResponseDto', () {
// List<String> existingIds (default value: const [])
test('to test the property `existingIds`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,42 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for ServerStatsResponseDto
void main() {
// final instance = ServerStatsResponseDto();
group('test ServerStatsResponseDto', () {
// int photos
test('to test the property `photos`', () async {
// TODO
});
// int videos
test('to test the property `videos`', () async {
// TODO
});
// int objects
test('to test the property `objects`', () async {
// TODO
});
// UsagePerUser diskUsagesByUser
test('to test the property `diskUsagesByUser`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,42 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for UsageByUserDto
void main() {
// final instance = UsageByUserDto();
group('test UsageByUserDto', () {
// int usageRaw
test('to test the property `usageRaw`', () async {
// TODO
});
// num objects
test('to test the property `objects`', () async {
// TODO
});
// num videos
test('to test the property `videos`', () async {
// TODO
});
// num images
test('to test the property `images`', () async {
// TODO
});
});
}

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.31.0+49
version: 1.33.0+52
environment:
sdk: ">=2.17.0 <3.0.0"

View File

@@ -0,0 +1,159 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:openapi/api.dart';
void main() {
final List<AssetResponseDto> testAssets = [];
for (int i = 0; i < 150; i++) {
int month = i ~/ 31;
int day = (i % 31).toInt();
DateTime date = DateTime(2022, month, day);
testAssets.add(AssetResponseDto(
type: AssetTypeEnum.IMAGE,
id: '$i',
deviceAssetId: '',
ownerId: '',
deviceId: '',
originalPath: '',
resizePath: '',
createdAt: date.toIso8601String(),
modifiedAt: date.toIso8601String(),
isFavorite: false,
mimeType: 'image/jpeg',
duration: '',
webpPath: '',
encodedVideoPath: '',
));
}
final Map<String, List<AssetResponseDto>> groups = {
'2022-01-05': testAssets.sublist(0, 5).map((e) {
e.createdAt = DateTime(2022, 1, 5).toIso8601String();
return e;
}).toList(),
'2022-01-10': testAssets.sublist(5, 10).map((e) {
e.createdAt = DateTime(2022, 1, 10).toIso8601String();
return e;
}).toList(),
'2022-02-17': testAssets.sublist(10, 15).map((e) {
e.createdAt = DateTime(2022, 2, 17).toIso8601String();
return e;
}).toList(),
'2022-10-15': testAssets.sublist(15, 30).map((e) {
e.createdAt = DateTime(2022, 10, 15).toIso8601String();
return e;
}).toList()
};
group('Asset only list', () {
test('items < itemsPerRow', () {
final assets = testAssets.sublist(0, 2);
final renderList = assetsToRenderList(assets, 3);
expect(renderList.length, 1);
expect(renderList[0].assetRow!.assets.length, 2);
});
test('items = itemsPerRow', () {
final assets = testAssets.sublist(0, 3);
final renderList = assetsToRenderList(assets, 3);
expect(renderList.length, 1);
expect(renderList[0].assetRow!.assets.length, 3);
});
test('items > itemsPerRow', () {
final assets = testAssets.sublist(0, 20);
final renderList = assetsToRenderList(assets, 3);
expect(renderList.length, 7);
expect(renderList[6].assetRow!.assets.length, 2);
});
test('items > itemsPerRow partition 4', () {
final assets = testAssets.sublist(0, 21);
final renderList = assetsToRenderList(assets, 4);
expect(renderList.length, 6);
expect(renderList[5].assetRow!.assets.length, 1);
});
test('items > itemsPerRow check ids', () {
final assets = testAssets.sublist(0, 21);
final renderList = assetsToRenderList(assets, 3);
expect(renderList.length, 7);
expect(renderList[6].assetRow!.assets.length, 3);
expect(renderList[0].assetRow!.assets[0].id, '0');
expect(renderList[1].assetRow!.assets[1].id, '4');
expect(renderList[3].assetRow!.assets[2].id, '11');
expect(renderList[6].assetRow!.assets[2].id, '20');
});
});
group('Test grouped', () {
test('test grouped check months', () {
final renderList = assetGroupsToRenderList(groups, 3);
// Jan
// Day 1
// 5 Assets => 2 Rows
// Day 2
// 5 Assets => 2 Rows
// Feb
// Day 1
// 5 Assets => 2 Rows
// Oct
// Day 1
// 15 Assets => 5 Rows
expect(renderList.length, 18);
expect(renderList[0].type, RenderAssetGridElementType.monthTitle);
expect(renderList[0].date.month, 1);
expect(renderList[7].type, RenderAssetGridElementType.monthTitle);
expect(renderList[7].date.month, 2);
expect(renderList[11].type, RenderAssetGridElementType.monthTitle);
expect(renderList[11].date.month, 10);
});
test('test grouped check types', () {
final renderList = assetGroupsToRenderList(groups, 5);
// Jan
// Day 1
// 5 Assets
// Day 2
// 5 Assets
// Feb
// Day 1
// 5 Assets
// Oct
// Day 1
// 15 Assets => 3 Rows
final types = [
RenderAssetGridElementType.monthTitle,
RenderAssetGridElementType.dayTitle,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.dayTitle,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.monthTitle,
RenderAssetGridElementType.dayTitle,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.monthTitle,
RenderAssetGridElementType.dayTitle,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.assetRow
];
expect(renderList.length, types.length);
for (int i = 0; i < renderList.length; i++) {
expect(renderList[i].type, types[i]);
}
});
});
}

View File

@@ -1,5 +1,8 @@
FROM docker.io/nginxinc/nginx-unprivileged:latest
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
COPY nginx.conf "/etc/nginx/nginx.conf"
CMD nginx -g "daemon off;"

21
nginx/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,4 +1,5 @@
node_modules/
upload/
dist/
coverage/
.reverse-geocoding-dump

View File

@@ -1,4 +1,3 @@
# Build stage
FROM node:16-alpine3.14 as builder
WORKDIR /usr/src/app
@@ -12,16 +11,18 @@ COPY . .
RUN npm run build
# Prod stage
FROM node:16-alpine3.14
WORKDIR /usr/src/app
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
COPY package.json package-lock.json ./
COPY start-server.sh start-microservices.sh ./
RUN mkdir -p /usr/src/app/dist \
&& mkdir /usr/src/app/.reverse-geocoding-dump \
&& apk add --no-cache libheif vips ffmpeg
COPY --from=builder /usr/src/app/node_modules ./node_modules
@@ -29,6 +30,11 @@ COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production
RUN chown -R node:0 /usr/src/app \
&& chmod -R g=u /usr/src/app
RUN addgroup node root
VOLUME /usr/src/app/upload
EXPOSE 3001

21
server/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -137,6 +137,7 @@ describe('Album service', () => {
getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);

View File

@@ -10,6 +10,9 @@ import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { In } from 'typeorm/find-options/operator/In';
export interface IAssetRepository {
create(
@@ -32,6 +35,7 @@ export interface IAssetRepository {
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<CheckExistingAssetsResponseDto>;
}
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@@ -279,4 +283,17 @@ export class AssetRepository implements IAssetRepository {
relations: ['exifInfo'],
});
}
async getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<CheckExistingAssetsResponseDto> {
const existingAssets = await this.assetRepository.find({
select: {deviceAssetId: true},
where: {
deviceAssetId: In(checkDuplicateAssetDto.deviceAssetIds),
deviceId: checkDuplicateAssetDto.deviceId,
userId,
},
});
return new CheckExistingAssetsResponseDto(existingAssets.map(a => a.deviceAssetId));
}
}

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