Compare commits

...

100 Commits

Author SHA1 Message Date
Alex Tran
1ba998aa68 Added changlog for Fdroid release 2022-07-27 13:17:36 -05:00
Alex Tran
2de34f70ce Update readme 2022-07-27 13:09:52 -05:00
Alex Tran
8b9fd67d6f Remove AxiosError import due to production build error 2022-07-27 13:01:49 -05:00
Alex
97238a1621 Up version for release 2022-07-27 11:39:19 -05:00
Alex
ef4136d327 [WEB] Select album thumbnail (#383)
* Added context menu for album opionts

* choose asset for album thumbnail

* Refactor UpdateAlbumDto to accept albumThumbnailAssetId

* implemented changing album cover on web

* Fixed api change on mobile app
2022-07-27 11:16:02 -05:00
Alex
6dbca8d478 Added Japanese/Polish/Finish and fix Italian/Spanish translation 2022-07-27 11:14:21 -05:00
Alex
a305db9e6f [Localizely] Translations update (#384) 2022-07-27 11:07:37 -05:00
Alex Tran
59c1ea3097 Added 2-stage loading for album's thumbnail 2022-07-26 22:06:06 -05:00
Alex
03457f5d32 [WEB] Upload asset directly to album (#379)
* Added stores to get album assetId

* Upload assets and add to album

* Added comments

* resolve conflict when add assets from upload directly

* Filtered out duplicate asset before adding to the album
2022-07-26 20:53:25 -05:00
Alex
2336a6159c [WEB] Load thumbnail with native source property for faster load time (#378) 2022-07-26 15:13:08 -05:00
Alex Tran
e4c4b53fcd Added imageName as searchable text on database 2022-07-26 13:43:12 -05:00
Alex
83cbf51704 Use cookies for client requests (#377)
* Use cookie for frontend request

* Remove api helper to use SDK

* Added error handling to status box

* Remove additional places that check for session.user

* Refactor sending password

* prettier clean up

* remove deadcode

* Move all authentication requests to the client

* refactor upload panel to only fetch assets after the upload panel disappear

* Added keydown to remove focus on title change on album viewer
2022-07-26 12:28:07 -05:00
Alex Tran
2ebb755f00 Fixed corner radius for message board 2022-07-24 23:45:05 -05:00
Alex Tran
ec1c3a86f5 Added messages when there is no album or shared album 2022-07-24 23:30:30 -05:00
Alex
969f770df0 Delete album on web (#373)
* Show context menu

* Show context menu at the correct location

* Implement delete album button

* Delete album within album viewer
2022-07-24 22:47:12 -05:00
Alex Tran
9c3f848fa8 Use Webp for album thumbnail 2022-07-24 08:51:00 -05:00
Alex Tran
1ea6425cd1 Handle unhandled promises that lead to unable to login 2022-07-24 08:41:06 -05:00
Alex
052db5d748 Remove/Add asset in ablum on web (#371)
* Added interaction to select multiple thumbnail

* Fixed stutter transition

* Return AlbumResponseDto after removing an asset from album

* Render correctly when an array of thumbnail is updated

* Fixed wording

* Added native dialog for removing users from album

* Fixed rendering incorrect profile image on share user select dialog
2022-07-23 23:23:14 -05:00
bo0tzz
a35460cb84 Bump tfjs version to 3.19.0 for arm64 support (#368)
* Add linux/arm64 to machine-learning container build

* Bump tfjs version to 3.19.0

* Fix tfjs dependency error
2022-07-23 14:15:55 -05:00
Alex
ae93bbe2a7 Docker login only with branch from the repository (#370) 2022-07-23 13:48:53 -05:00
Alex
3b97c7729b Implement mechanism to remove and add shared user in album on web (#369)
* AFixed overlay issue of modal

* Added modal with existing user

* Added custom scrollbar to all pages

* Fixed Document is not define when access document DOM node in browswer

* Added context menu

* Added api to remove user from album

* Handle user leave album

* Added share button to non-shared album

* Added padding to album viewer:

* Fixed margin top of asset selection page

* Fixed issue cannot push to dockerhub
2022-07-23 13:08:49 -05:00
bo0tzz
6021124688 Move docker login step to after build (#367) 2022-07-23 11:05:13 -05:00
Alex
1d34976dd0 Implement album creation on web (#365)
* Added album creation button functionality

* Added input for album title

* Added select photos button

* Added page to select assets

* Show photo selection timeline

* Implemented update album name mechanism:

* Added selection mechanism

* Added selection mechanism with existing assets in album

* Refactored and added comments

* Refactored and added comments - 2

* Refactor album app bar

* Added modal for select user

* Implemented choose users

* Added additional share user button

* Added rule to show add users button
2022-07-22 09:44:22 -05:00
dependabot[bot]
02bde51caf Bump docker/build-push-action from 3.0.0 to 3.1.0 (#363)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3.0.0 to 3.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3.0.0...v3.1.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>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-20 20:49:11 -05:00
Matthias Rupp
bef1e2e3db Api logout route (#361)
* Add logout route that deletes http only cookies

* Rebuild API
2022-07-19 13:49:58 -05:00
Alex
be3e3e5d7e Added Cookie Authentication (#360)
* Added Cookie Authentication

* Fixed issue with bearer is in lower case

* Fixed bearer to Bearer to conform with standard
2022-07-18 14:14:25 -05:00
Alex
c028c7db4e dev/add detail viewer to album (#358)
* Rename asset viewer folder

* Refactor AssetViewer to be able to user with different component

* Refactor AssetViewer to be able to user with different component

* Added viewer for album and sharing
2022-07-18 00:22:39 -05:00
Alex Tran
c129023821 Remove console.log 2022-07-17 15:10:04 -05:00
Alex Tran
cbdb8fa51f Update get user info controller to avoid conflict with /count 2022-07-17 15:09:26 -05:00
Alex
c6ecfb679a Added sharing page to web (#355)
* Added shared album

* Added list tile

* Show info of shared album owner
2022-07-16 23:52:00 -05:00
Alex
5d03e9bda8 Fix test instance cannot clear database after each test" (#354)
* Update test

* Fixed test cannot initialize database

* Added a separate network to test containers group to run test while in development mode
2022-07-16 23:43:31 -05:00
Alex Tran
d8b26c6da8 Update bug report template 2022-07-16 10:54:00 -05:00
Alex
2e61cf3183 Update README.md
Fixed incorrect info about microservices container
2022-07-16 07:15:22 -05:00
Alex Tran
45e2335b86 Allow manually run test workflow 2022-07-16 00:48:35 -05:00
Alex Tran
2bbc44c5ab Fixed test 2022-07-16 00:45:58 -05:00
Alex Tran
012428416d Remove console.log 2022-07-15 23:27:23 -05:00
Alex
7134f93eb8 Add ablum feature to web (#352)
* Added album page

* Refactor sidebar

* Added album assets count info

* Added album viewer page

* Refactor album sorting

* Fixed incorrectly showing selected asset in album selection

* Improve fetching speed with prefetch

* Refactor to use ImmichThubmnail component for all

* Update to the latest version of Svelte

* Implement fixed app bar in album viewer

* Added shared user avatar

* Correctly get all owned albums, including shared
2022-07-15 23:18:17 -05:00
Jaime Baez
1887b5a860 Add email validation in the API when creating new users (#350)
* Refactor user.service - add user-repository

* Add email validation for creating users
2022-07-15 14:30:56 -05:00
Jaime Baez
ef17668871 Update Spanish translations (#348)
- add missing translations
- remove extra white spaces
- spelling and other corrections
2022-07-14 13:59:07 -05:00
Alex
e9909b179a Up version for release 2022-07-14 11:39:06 -05:00
Alex
09f8bdef6d Up version for release 2022-07-14 11:32:07 -05:00
Alex
2a9b09f359 Added DA,ES,FR,IT (#347)
* Added DA,ES,FR,IT

* Update French translation
2022-07-14 10:20:23 -05:00
Alex
1f6a3ccac7 Added local code in localizely config file 2022-07-14 08:39:43 -05:00
Eidenz
1f40fc1de9 Add missing translation tag for search_no_objects (#344)
* feat(mobile) added french translations

* fix(mobile) added missing translation tag for search_no_objects (EN,DE,FR)

* fix(mobile) renamed search_no_objects to search_page_no_objects
2022-07-13 20:46:28 -05:00
Eidenz
20b94ef0bb feat(mobile) added french translations (#343) 2022-07-13 11:35:02 -05:00
Alex Tran
72c334e5e0 Pump build number 2022-07-13 10:12:03 -05:00
Alex Tran
e7f35822af Pump version number 2022-07-13 10:04:25 -05:00
Alex Tran
bd2152d568 Pump build number 2022-07-13 09:56:34 -05:00
Alex
b1d7ef03e2 Pump version for release (#339)
* Remove unncessesary line

* Pump version for release
2022-07-13 09:51:41 -05:00
Alex Tran
aa74417d11 Fixed web production build 2022-07-13 08:35:52 -05:00
Alex Tran
229b009b7f Remove Axios import in web hook.ts 2022-07-13 08:25:43 -05:00
Fynn Petersen-Frey
bece6253d5 Improve Docker setup and reduce memory usage of production containers (#338) 2022-07-13 07:42:00 -05:00
Alex
ae7e582ec8 Refactor mobile to use OpenApi generated SDK (#336) 2022-07-13 07:23:48 -05:00
Fynn Petersen-Frey
d69470e207 Add extended redis & DB port configuration via environment variables (#330)
* Add database port configuration via env variable

Add redis connection configuration via env variables

* Add redis connection configuration via env variables
2022-07-12 22:21:11 -05:00
Alex
c60e852226 fix 331 (#335)
* fix #331 - Video with no date information in EXIF upload via web caused mobile client not able to render other assets
2022-07-12 16:34:43 -05:00
Zack Pollard
a205478a29 Remove advice regarding running immich-server with scaling (#334) 2022-07-11 11:40:25 -05:00
Alex
22d30522e1 [Localizely] Translations update (#324) 2022-07-10 22:31:29 -05:00
Matthias Rupp
19b1fad274 Add message to login screen (useful for demo instances) (#329)
* Add message for demo instances to login screen

* Rename env variable

* Added key into

* Add styling to conform with Immich color scheme

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-07-10 22:31:17 -05:00
Alex
9a6dfacf9b Refactor web to use OpenAPI SDK (#326)
* Refactor main index page

* Refactor admin page

* Refactor Auth endpoint

* Refactor directory to prep for monorepo

* Fixed refactoring path

* Resolved file path in vite

* Refactor photo index page

* Refactor thumbnail

* Fixed test

* Refactor Video Viewer component

* Refactor download file

* Refactor navigation bar

* Refactor upload file check

* Simplify Upload Asset signature

* PR feedback
2022-07-10 21:41:45 -05:00
Alex
7f236c5b18 Add OpenAPI Specs and Response DTOs (#320)
* Added swagger bearer auth method authentication accordingly

* Update Auth endpoint

* Added additional api information for authentication

* Added Swagger CLI pluggin

* Added DTO for /user endpoint

* Added /device-info reponse DTOs

* Implement server version

* Added DTOs for /server-info

* Added DTOs for /assets

* Added album to Swagger group

* Added generated specs file

* Add Client API generator for web

* Remove incorrectly placed node_modules

* Created class to handle access token

* Remove password and hash when getting all user

* PR feedback

* Fixed video from CLI doesn't get metadata extracted

* Fixed issue with TSConfig to work with generated openAPI

* PR feedback

* Remove console.log
2022-07-08 21:26:50 -05:00
Matthias Rupp
25985c732d Merge pull request #323 from alextran1502/fix/localizely_format
Change localizely format to json
2022-07-08 15:56:49 +02:00
Matthias
9ce50b7e3d Change localizely format to json 2022-07-08 15:53:20 +02:00
Matthias Rupp
f5e93a8179 Add translation keys for upload info section (#319) 2022-07-07 17:25:02 -05:00
Matthias Rupp
2b5cef156c Internationalization (German) of the mobile app. (#246)
* Add i18n framework to mobile app and write simple translation generator

* Replace all texts in login_form with i18n keys

* Localization of sharing section

* Localization of asset viewer section

* Use JSON as base translation format

* Add check for missing/unused translation keys

* Add localizely

* Remove i18n directory in favour of localizely

* Backup Translation

* More translations

* Translate home page

* Translation of search page

* Translate new server version announcement

* Reformat code

* Fix typo in german translation

* Update englisch translations

* Change translation keys to match dart filenames

* Add /api to translated endpoint_urls

* Update localizely.yml

* Add languages to ios plist

* Remove unused keys

* Added script to check outdated key in other translations

* Add download key to localizely.yml

Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-07-07 13:40:54 -05:00
Alex
f3032f74a4 Added changelog for Fdroid 2022-07-06 22:35:07 -05:00
Alex
58ec7553ea Add information for uploading asset and error indication with error message for each failed upload. (#315)
* Added info box

* Fixed upload endpoint doesn't report error status code

* Added chip to show update error

* Added chip to show failed upload

* Add duplication check for upload

* Better duplication-checking placement

* Remove check for duplicated asset

* Added failed backup status route

* added page

* Display error card with thumbnail

* Improved styling

* Set thumbnail with better quality

* Remove force upload error
2022-07-06 16:12:55 -05:00
Alex
357f7d1c31 Added schedule job to perform reverse geocoding if key is added after backing up assets (#305) 2022-07-04 15:16:39 -05:00
Zack Pollard
e6d30d72fa Fix typeorm migrations (#297)
* fix: remove config parameter from typeorm cli and update config

the config parameter is no longer supported since version 0.3
the config now needs to export a DataSource object to work with the 0.3 cli

* fix: update all typeorm entities and migrations to be aligned with database structure

* Fixed test-util import databaseConfig

* Fixed column mismatch in raw query with new migration

* Remove dist build directory when starting dev server

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-07-04 14:20:43 -05:00
Jaime Baez
355038a91a Use npm ci for installing pacakages (#304) 2022-07-04 13:47:25 -05:00
Alex
97d9b80baa Added creation date for video from ffmpeg.prob (#303) 2022-07-04 13:44:43 -05:00
Alex Tran
b6814fad57 Up version for hotfix 2022-07-03 20:55:30 -05:00
Alex
7586c65103 Fix cannot query shared album on mobile (#298) 2022-07-03 20:52:03 -05:00
Alex
633170d743 Fixed inconnect image grouping with the same date but different year (#296) 2022-07-03 18:00:56 -05:00
Alex Tran
c5be7827c3 Remove 2284 to avoid confusion since 443 is not exposed from internal proxy 2022-07-03 11:37:26 -05:00
Alex Tran
e84c705e31 Added changelog to Fdroid 2022-07-03 10:49:37 -05:00
Alex Tran
36162509e0 Up version for release 2022-07-03 10:39:09 -05:00
Alex
76bf1c0379 Remove thumbnail generation on mobile app (#292)
* Remove thumbnail generation on mobile

* Remove tconditions for missing thumbnail on the backend

* Remove console.log

* Refactor queue systems

* Convert queue and processor name to constant

* Added corresponding interface to job queue
2022-07-02 21:06:36 -05:00
Alex
32b847c26e Fixed event propagation trigger navigating twice (#293) 2022-07-01 20:49:41 -05:00
Alex
a45d6fdf57 Fix server crash on bad file operation and other optimizations (#291)
* Fixed issue with generating thumbnail for video with 0 length cause undefined file and crash the server
* Added all file error handling operation
* Temporarily disabled WebSocket on the web because receiving a new upload event doesn't put the new file in the correct place. 
* Cosmetic fixed on the info panel
2022-07-01 12:00:12 -05:00
Zack Pollard
c071e64a7e infra: switch port to 3003 for machine learning container (#290)
* infra: switch port to 3003 for machine learning container

fixes #289

* Changed port of machine-learning-endpoint to match with new port

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-07-01 10:20:04 -05:00
Alex
663f12851e Fixed filename duplication when upload from web (#288)
* Fixed filename duplication when upload from web

* Fixed cosmetic of detail panel view
2022-06-30 20:43:33 -05:00
xpwmaosldk
c4ef523564 Optimize mobile - Avoid creating unnecessary widgets (#268)
* Avoid creating unnecessary widgets

* more flexible null handling and runtime errors prevention
2022-06-30 20:08:49 -05:00
Alex
992f792c0a Fixed admin is forced to change password on mobile app (#287)
* Fixed issues

* Upversion and add changed log
2022-06-30 13:59:02 -05:00
Alex Tran
97611fa057 Fixed issue with unexposed Nginx port on release image 2022-06-30 00:26:54 -05:00
Alex Tran
32240777c3 fixed release build directory for Github action 2022-06-30 00:10:01 -05:00
Alex Tran
6065ff8caa Update readme with new discord invitation link 2022-06-29 23:50:24 -05:00
Alex Tran
8db073941d Up server version for release 2022-06-29 21:54:57 -05:00
Alex
5e281b44e9 Add Podman Support (#278) 2022-06-29 21:49:35 -05:00
Zack Pollard
142ede350e feat: create immich-nginx container to remove default nginx config setup (#280)
* feat: create immich-proxy container to remove default nginx config setup

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

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

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

* Adapt typeorm API to be compatible with new version

* Fixed typeorm API in tests

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

* refactor and simplify setup

* Added user info to change password form

* change isFistLogin column to shouldChangePassword

* Implemented change user password

* Implement the change password page for mobile

* Change label

* Added changes log and up minor version

* Fixed typo in the release note

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

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

* Fixed grammar

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

* Format code
2022-06-25 15:12:47 -05:00
xpwmaosldk
d02b97e1c1 Add service provider (#250)
* optimize android side gradle settings

* android minsdk back to 21

* remove unused package, update linter and fix lint error

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

* restore uploadProfileImage method in UserService

* add service provider

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

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

* Fix the rest of TS issues

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

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

* Update AssetReponseDto duration type

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

* Set context when logging error in asset.service

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

* Fix wrong AppController merge conflict resolution

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

* Remove commented line
2022-06-24 10:02:09 -05:00
Alex
dfc0d6eee7 Update readme with additional image 2022-06-24 07:28:56 -05:00
Alex Tran
7948cb8110 remove redirect from previous implementation of web routing 2022-06-24 06:33:11 -05:00
552 changed files with 31885 additions and 8621 deletions

View File

@@ -16,8 +16,11 @@ Note: Please search to see if an issue already exists for the bug you encountere
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**Task List** **Task List**
*Please complete the task list below. We need this information to help us reproduce the bug or point out problems in your setup. You are not providing enough info may delay our effort to help you.*
- [ ] I have read thoroughly the README setup and installation instructions. - [ ] I have read thoroughly the README setup and installation instructions.
- [ ] If my setup is different, I have included my docker-compose file. - [ ] I have included my `docker-compose` file.
- [ ] I have included my redacted `.env` file. - [ ] I have included my redacted `.env` file.
- [ ] I have included information on my machine, and environment. - [ ] I have included information on my machine, and environment.
@@ -34,13 +37,10 @@ A clear and concise description of what you expected to happen.
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):** **System**
- OS: [e.g. iOS] - Phone OS [iOS, Android]: `<version>`
- Server Version: `<version>`
**Smartphone (please complete the following information):** - Mobile App Version: `<version>`
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Version [e.g. 22]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

View File

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

View File

@@ -24,17 +24,18 @@ jobs:
id: buildx id: buildx
uses: docker/setup-buildx-action@v2.0.0 uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub - name: Login to Docker Hub
if: ${{ github.repository == 'alextran1502/immich' }}
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich Mono Repo - name: Build and push Immich Mono Repo
uses: docker/build-push-action@v3.0.0 uses: docker/build-push-action@v3.1.0
with: with:
context: ./server context: ./server
file: ./server/Dockerfile file: ./server/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64 platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name == 'pull_request' }} push: ${{ github.event_name == 'pull_request' && github.repository == 'alextran1502/immich' }}
tags: | tags: |
altran1502/immich-server:staging altran1502/immich-server:staging
@@ -52,17 +53,18 @@ jobs:
id: buildx id: buildx
uses: docker/setup-buildx-action@v2.0.0 uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub - name: Login to Docker Hub
if: ${{ github.repository == 'alextran1502/immich' }}
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning - name: Build and Push Machine Learning
uses: docker/build-push-action@v3.0.0 uses: docker/build-push-action@v3.1.0
with: with:
context: ./machine-learning context: ./machine-learning
file: ./machine-learning/Dockerfile file: ./machine-learning/Dockerfile
platforms: linux/arm/v7,linux/amd64 platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name == 'pull_request' }} push: ${{ github.event_name == 'pull_request' && github.repository == 'alextran1502/immich' }}
tags: | tags: |
altran1502/immich-machine-learning:staging altran1502/immich-machine-learning:staging
@@ -79,17 +81,46 @@ jobs:
id: buildx id: buildx
uses: docker/setup-buildx-action@v2.0.0 uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub - name: Login to Docker Hub
if: ${{ github.repository == 'alextran1502/immich' }}
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web - name: Build and Push Web
uses: docker/build-push-action@v3.0.0 uses: docker/build-push-action@v3.1.0
with: with:
context: ./web context: ./web
file: ./web/Dockerfile file: ./web/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64 platforms: linux/arm/v7,linux/amd64,linux/arm64
target: prod target: prod
push: ${{ github.event_name == 'pull_request' }} push: ${{ github.event_name == 'pull_request' && github.repository == 'alextran1502/immich' }}
tags: | tags: |
altran1502/immich-web:staging altran1502/immich-web:staging
build_and_push_nginx_staging:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
if: ${{ github.repository == 'alextran1502/immich' }}
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.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name == 'pull_request' && github.repository == 'alextran1502/immich' }}
tags: |
altran1502/immich-proxy:staging

View File

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

View File

@@ -1,5 +1,6 @@
name: Test name: Test
on: on:
workflow_dispatch:
pull_request: pull_request:
push: { branches: master } push: { branches: master }
@@ -14,4 +15,4 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Run Immich Server 2E2 Test - name: Run Immich Server 2E2 Test
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich_server_test run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test

View File

@@ -1,20 +1,26 @@
dev: dev:
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-update: dev-update:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale: dev-scale:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
stage: stage:
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
pull-stage:
docker-compose -f ./docker/docker-compose.staging.yml pull
test-e2e: test-e2e:
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich_server_test --remove-orphans docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
prod: prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
prod-scale: prod-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=5 --scale immich-microservices=3 --remove-orphans docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
api:
cd ./server && npm run api:generate

View File

@@ -10,7 +10,7 @@
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main"> <a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" /> <img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" />
</a> </a>
<a href="https://discord.gg/rxnyVTXGbM"> <a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/> <img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
</a> </a>
<br/> <br/>
@@ -65,22 +65,19 @@ This project is under heavy development, there will be continuous functions, fea
| Selective album(s) for backup | Yes | N/A | Selective album(s) for backup | Yes | N/A
| Download photos and videos to local device | Yes | Yes | Download photos and videos to local device | Yes | Yes
| Multi-user support | Yes | Yes | Multi-user support | Yes | Yes
| Shared Albums | Yes | No | Album | No | Yes
| Shared Albums | Yes | Yes
| Quick navigation with draggable scrollbar | Yes | Yes | Quick navigation with draggable scrollbar | Yes | Yes
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes | Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
| Metadata view (EXIF, map) | Yes | Yes | Metadata view (EXIF, map) | Yes | Yes
| Search by metadata, objects and image tags | Yes | No | Search by metadata, objects and image tags | Yes | No
| Administrative functions (user management) | No | Yes | Administrative functions (user management) | N/A | Yes
# System Requirement # System Requirement
**OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc). **OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
I haven't tested with `Docker for Windows` as well as `WSL` on Windows
*Raspberry Pi can be used but `microservices` container has to be comment out in `docker-compose` since TensorFlow has not been supported in Docker image on arm64v7 yet.*
**RAM**: At least 2GB, preffered 4GB. **RAM**: At least 2GB, preffered 4GB.
**Core**: At least 2 cores, preffered 4 cores. **Core**: At least 2 cores, preffered 4 cores.
@@ -155,19 +152,13 @@ To **start**, run
docker-compose -f ./docker/docker-compose.yml up docker-compose -f ./docker/docker-compose.yml up
``` ```
If you have a few thousand photos/videos, I suggest running docker-compose with *scaling* option for the `immich_server` container to handle high I/O load when using fast scrolling.
```bash
docker-compose -f ./docker/docker-compose.yml up --scale immich-server=5
```
To *update* docker-compose with newest image (if you have started the docker-compose previously) To *update* docker-compose with newest image (if you have started the docker-compose previously)
```bash ```bash
docker-compose -f ./docker/docker-compose.yml pull && docker-compose -f ./docker/docker-compose.yml up docker-compose -f ./docker/docker-compose.yml pull && docker-compose -f ./docker/docker-compose.yml up
``` ```
The server will be running at `http://your-ip:2283/api` through `Nginx` The server will be running at `http://your-ip:2283/api`
## Step 3: Register User ## Step 3: Register User
@@ -185,7 +176,11 @@ Additional accounts on the server can be created by the admin account.
## Step 4: Run mobile app ## Step 4: Run mobile app
The app is distributed on several platforms below. Login the mobile app with your server address
<p align="left">
<img src="design/login-screen.jpeg" width="250" title="Example login screen">
<p/>
## F-Droid ## F-Droid
You can get the app on F-droid by clicking the image below. You can get the app on F-droid by clicking the image below.
@@ -227,6 +222,15 @@ make dev # required Makefile installed on the system.
All servers and web container are hot reload for quick feedback loop. All servers and web container are hot reload for quick feedback loop.
## Note for developers
### 1 - OpenAPI
OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generator-cli` can be installed [here](https://openapi-generator.tech/docs/installation/). When you add a new or modify an existing endpoint, you must run the generate command below to update the client SDK.
```bash
npm run api:generate # Run from server directory
```
You can find the generated client SDK in the [`web/src/api`](web/src/api) for Typescript SDK and [`mobile/openapi`](mobile/openapi) for Dart SDK.
# Support # Support
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**Github Sponsor**](https://github.com/sponsors/alextran1502), or a one time donation with the Buy Me a coffee link below. If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**Github Sponsor**](https://github.com/sponsors/alextran1502), or a one time donation with the Buy Me a coffee link below.
@@ -241,7 +245,7 @@ Cheers! 🎉
## TensorFlow Build Issue ## TensorFlow Build Issue
*This is a known issue on RaspberryPi 4 arm64-v7 and incorrect Promox setup* *This is a known issue for incorrect Promox setup*
TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`: TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
@@ -254,7 +258,3 @@ If you are running virtualization in Promox, the VM doesn't have the flag enable
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab. You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
`Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host` `Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`
Otherwise you can:
- edit `docker-compose.yml` file and comment the whole `immich-machine-learning` service **which will disable machine learning features like object detection and image classification**
- switch to a different VM/desktop with different architecture.

BIN
design/login-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -7,6 +7,8 @@ DB_USERNAME=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_DATABASE_NAME=immich DB_DATABASE_NAME=immich
# Optional Database settings:
# DB_PORT=5432
@@ -17,6 +19,12 @@ DB_DATABASE_NAME=immich
REDIS_HOSTNAME=immich_redis REDIS_HOSTNAME=immich_redis
# Optional Redis settings:
# REDIS_PORT=6379
# REDIS_DBINDEX=0
# REDIS_PASSWORD=
# REDIS_SOCKET=
@@ -51,7 +59,7 @@ MAPBOX_KEY=
################################################################################### ###################################################################################
# WEB # WEB - Required
################################################################################### ###################################################################################
# This is the URL of your vm/server where you host Immich, so that the web frontend # This is the URL of your vm/server where you host Immich, so that the web frontend
@@ -61,3 +69,13 @@ MAPBOX_KEY=
# !CAUTION! THERE IS NO FORWARD SLASH AT THE END # !CAUTION! THERE IS NO FORWARD SLASH AT THE END
VITE_SERVER_ENDPOINT= VITE_SERVER_ENDPOINT=
####################################################################################
# WEB - Optional
####################################################################################
# Custom message on the login page, should be written in HTML form.
# For example VITE_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
VITE_LOGIN_PAGE_MESSAGE=

View File

@@ -1,5 +1,5 @@
# Database # Database
DB_HOSTNAME=immich_postgres_test DB_HOSTNAME=immich-database-test
DB_USERNAME=postgres DB_USERNAME=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_DATABASE_NAME=e2e_test DB_DATABASE_NAME=e2e_test

View File

@@ -7,8 +7,6 @@ services:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
command: npm run start:dev immich command: npm run start:dev immich
expose:
- "3000"
volumes: volumes:
- ../server:/usr/src/app - ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -27,8 +25,6 @@ services:
context: ../machine-learning context: ../machine-learning
dockerfile: Dockerfile dockerfile: Dockerfile
command: npm run start:dev command: npm run start:dev
expose:
- "3001"
volumes: volumes:
- ../machine-learning:/usr/src/app - ../machine-learning:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -68,12 +64,14 @@ services:
env_file: env_file:
- .env - .env
ports: ports:
- 3002:3000 - 3000:3000
- 24678:24678 - 24678:24678
volumes: volumes:
- ../web:/usr/src/app - ../web:/usr/src/app
- /usr/src/app/node_modules - /usr/src/app/node_modules
restart: always restart: always
depends_on:
- immich-server
redis: redis:
container_name: immich_redis container_name: immich_redis
@@ -94,11 +92,12 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
nginx: immich-proxy:
container_name: proxy_nginx container_name: immich_proxy
image: nginx:latest image: immich-proxy-dev:latest
volumes: build:
- ./settings/nginx-conf:/etc/nginx/conf.d context: ../nginx
dockerfile: Dockerfile
ports: ports:
- 2283:80 - 2283:80
- 2284:443 - 2284:443
@@ -106,6 +105,7 @@ services:
driver: none driver: none
depends_on: depends_on:
- immich-server - immich-server
restart: always
volumes: volumes:
pgdata: pgdata:

View File

@@ -68,11 +68,9 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
restart: always restart: always
nginx: immich-proxy:
container_name: proxy_nginx container_name: immich_proxy
image: nginx:latest image: altran1502/immich-proxy:staging
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
ports: ports:
- 2283:80 - 2283:80
- 2284:443 - 2284:443

View File

@@ -1,8 +1,8 @@
version: "3.8" version: "3.8"
services: services:
immich_server_test: immich-server-test:
image: immich-server-dev:latest image: immich-server-test
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -17,15 +17,17 @@ services:
environment: environment:
- NODE_ENV=development - NODE_ENV=development
depends_on: depends_on:
- redis - immich-redis-test
- database - immich-database-test
networks:
redis: - immich-test-network
container_name: immich_redis_test immich-redis-test:
container_name: immich-redis-test
image: redis:6.2 image: redis:6.2
networks:
database: - immich-test-network
container_name: immich_postgres_test immich-database-test:
container_name: immich-database-test
image: postgres:14 image: postgres:14
env_file: env_file:
- .env.test - .env.test
@@ -36,5 +38,8 @@ services:
PG_DATA: /var/lib/postgresql/data PG_DATA: /var/lib/postgresql/data
volumes: volumes:
- /var/lib/postgresql/data - /var/lib/postgresql/data
ports: networks:
- 5432:5432 - immich-test-network
networks:
immich-test-network:

View File

@@ -2,7 +2,7 @@ version: "3.8"
services: services:
immich-server: immich-server:
image: altran1502/immich-server:latest image: altran1502/immich-server:release
entrypoint: ["/bin/sh", "./start-server.sh"] entrypoint: ["/bin/sh", "./start-server.sh"]
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -16,7 +16,7 @@ services:
restart: always restart: always
immich-microservices: immich-microservices:
image: altran1502/immich-server:latest image: altran1502/immich-server:release
entrypoint: ["/bin/sh", "./start-microservices.sh"] entrypoint: ["/bin/sh", "./start-microservices.sh"]
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -30,7 +30,7 @@ services:
restart: always restart: always
immich-machine-learning: immich-machine-learning:
image: altran1502/immich-machine-learning:latest image: altran1502/immich-machine-learning:release
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -43,7 +43,7 @@ services:
restart: always restart: always
immich-web: immich-web:
image: altran1502/immich-web:latest image: altran1502/immich-web:release
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file: env_file:
- .env - .env
@@ -68,14 +68,11 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
restart: always restart: always
nginx: immich-proxy:
container_name: proxy_nginx container_name: immich_proxy
image: nginx:latest image: altran1502/immich-proxy:release
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
ports: ports:
- 2283:80 - 2283:80
- 2284:443
logging: logging:
driver: none driver: none
depends_on: depends_on:

19
localizely.yml Normal file
View File

@@ -0,0 +1,19 @@
config_version: 1.0
project_id: ead34689-ec52-41d9-b675-09bc85a6cbd7
file_type: json
upload:
files:
- file: mobile/assets/i18n/en-US.json
locale_code: en-US
- file: mobile/assets/i18n/de-DE.json
locale_code: de-DE
- file: mobile/assets/i18n/fr-FR.json
locale_code: fr-FR
download:
files:
- file: mobile/assets/i18n/en-US.json
locale_code: en-US
- file: mobile/assets/i18n/de-DE.json
locale_code: de-DE
- file: mobile/assets/i18n/fr-FR.json
locale_code: fr-FR

View File

@@ -9,7 +9,8 @@ COPY package.json package-lock.json ./
RUN apt-get update RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN npm install RUN npm ci
RUN npm rebuild @tensorflow/tfjs-node --build-from-source
COPY . . COPY . .

File diff suppressed because it is too large Load Diff

View File

@@ -28,11 +28,11 @@
"@nestjs/typeorm": "^8.0.3", "@nestjs/typeorm": "^8.0.3",
"@tensorflow-models/coco-ssd": "^2.2.2", "@tensorflow-models/coco-ssd": "^2.2.2",
"@tensorflow-models/mobilenet": "^2.1.0", "@tensorflow-models/mobilenet": "^2.1.0",
"@tensorflow/tfjs": "^3.15.0", "@tensorflow/tfjs": "^3.19.0",
"@tensorflow/tfjs-converter": "^3.15.0", "@tensorflow/tfjs-converter": "^3.19.0",
"@tensorflow/tfjs-core": "^3.15.0", "@tensorflow/tfjs-core": "^3.19.0",
"@tensorflow/tfjs-node": "^3.15.0", "@tensorflow/tfjs-node": "^3.19.0",
"@tensorflow/tfjs-node-gpu": "^3.15.0", "@tensorflow/tfjs-node-gpu": "^3.19.0",
"@trpc/server": "^9.20.3", "@trpc/server": "^9.20.3",
"pg": "^8.7.3", "pg": "^8.7.3",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",

View File

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

View File

@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
await app.listen(3001, () => { await app.listen(3003, () => {
if (process.env.NODE_ENV == 'development') { if (process.env.NODE_ENV == 'development') {
Logger.log( Logger.log(
'Running Immich Machine Learning in DEVELOPMENT environment', 'Running Immich Machine Learning in DEVELOPMENT environment',

View File

@@ -21,10 +21,18 @@ linter:
# or a specific dart file by using the `// ignore: name_of_lint` and # or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file # `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint. # producing the lint.
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
use_build_context_synchronously: false use_build_context_synchronously: false
require_trailing_commas: true
unrelated_type_equality_checks: true
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
analyzer:
exclude:
- openapi/
- openapi/test/
- lib/generated_plugin_registrant.dart

View File

@@ -1,2 +1,2 @@
json_key_file("/Users/alex/Documents/immich-fastlane-googleplaystore-key.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one json_key_file("/Users/alex/Documents/immich-play-store-key.json")
package_name("app.alextran.immich") # e.g. com.krausefx.app package_name("app.alextran.immich")

View File

@@ -16,10 +16,25 @@
default_platform(:android) default_platform(:android)
platform :android do platform :android do
desc "Build Android"
lane :build do
gradle(
task: 'bundle',
build_type: 'Release',
)
end
desc "Update AAB to PlayStore" desc "Build and Release Android"
lane :beta do lane :release do
upload_to_play_store(track: 'beta', aab: '../build/app/outputs/bundle/release/app-release.aab') gradle(
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 29,
"android.injected.version.name" => "1.19.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')
end end
end end

View File

@@ -15,10 +15,10 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## Android ## Android
### android beta ### android release
```sh ```sh
[bundle exec] fastlane android beta [bundle exec] fastlane android release
``` ```
Update AAB to PlayStore Update AAB to PlayStore

View File

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

View File

@@ -0,0 +1,2 @@
* Fixed admin is forced to change password upon logging in on mobile app
* Fixed change password form validation

View File

@@ -0,0 +1 @@
* Removed thumbnail generation on mobile - the operation now will be on the server to reduce CPU load and battery usage.

View File

@@ -0,0 +1 @@
* Hot fix: Restore shared album functionality

View File

@@ -0,0 +1 @@
* Add information for uploading asset and error indication with error message for each failed upload.

View File

@@ -0,0 +1 @@
* Refactored app to use OpenAPI SDK to improve performance and project structure.

View File

@@ -0,0 +1 @@
* Refactored app to use OpenAPI SDK to improve performance and project structure.

View File

@@ -0,0 +1 @@
* Added other languages to app

View File

@@ -0,0 +1 @@
* Added French, Danish, Spanish, French, Japanese, Polish, and Finish translation to the app

View File

@@ -5,14 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000318"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000204">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: upload_to_play_store" time="111.253169"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="11.673502">
<failure message="/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/runner.rb:229:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/runner.rb:229:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing&apos;&#10;Fastfile:22:in `block (2 levels) in parsing_binding&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/lane.rb:33:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/runner.rb:49:in `block in execute&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/runner.rb:45:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/runner.rb:45:in `execute&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/commands_generator.rb:109:in `block (2 levels) in run&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/commands_generator.rb:353:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/commands_generator.rb:42:in `start&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/fastlane/lib/fastlane/cli_tools_distributor.rb:122:in `take_off&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/gems/fastlane-2.204.3/bin/fastlane:23:in `&lt;top (required)&gt;&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/bin/fastlane:25:in `load&apos;&#10;/usr/local/Cellar/fastlane/2.204.3/libexec/bin/fastlane:25:in `&lt;main&gt;&apos;&#10;&#10;Google Api Error: Invalid request - APK specifies a version code that has already been used." /> </testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="37.162935">
</testcase> </testcase>

View File

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "EKSKLUDERET",
"album_info_card_backup_album_included": "INKLUDERET",
"album_viewer_appbar_share_delete": "Slet album",
"album_viewer_appbar_share_err_delete": "Fejlede sletning af album",
"album_viewer_appbar_share_err_leave": "Fejlede i at forlade album",
"album_viewer_appbar_share_err_remove": "Der er problemer med at fjerne elementer fra album",
"album_viewer_appbar_share_err_title": "Fejlede i at ændre albumtitel",
"album_viewer_appbar_share_leave": "Forlad album",
"album_viewer_appbar_share_remove": "Fjern fra album",
"album_viewer_page_share_add_users": "Tilføj brugere",
"backup_album_selection_page_albums_device": "Albummer på enhed ({})",
"backup_album_selection_page_albums_tap": "Tryk en gang for at inkludere, tryk to gange for at ekskludere",
"backup_album_selection_page_assets_scatter": "Elementer kan være spredt på tværs af flere albummer. Albummer kan således inkluderes eller udelukkes under sikkerhedskopieringsprocessen.",
"backup_album_selection_page_select_albums": "Vælg albummer",
"backup_album_selection_page_selection_info": "Oplysninger om valgte",
"backup_album_selection_page_total_assets": "Samlede unikke elementer",
"backup_all": "Alt",
"backup_controller_page_albums": "Sikkerhedskopier albummer",
"backup_controller_page_backup": "Sikkerhedskopier",
"backup_controller_page_backup_selected": "Valgte: ",
"backup_controller_page_backup_sub": "Sikkerhedskopierede billeder og videoer",
"backup_controller_page_cancel": "Annuller",
"backup_controller_page_created": "Oprettet den: {}",
"backup_controller_page_desc_backup": "Slå sikkerhedskopiering til automatisk at uploade nye elementer til serveren.",
"backup_controller_page_excluded": "Ekskluderet: ",
"backup_controller_page_failed": "Felet ({})",
"backup_controller_page_filename": "Filnavn: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Sikkerhedskopieringsinformation",
"backup_controller_page_none_selected": "Ingen valgte",
"backup_controller_page_remainder": "Tilbageværende",
"backup_controller_page_remainder_sub": "Tilbageværende billeder og albummer, at sikkerhedskopiere, fra valgte",
"backup_controller_page_select": "Vælg",
"backup_controller_page_server_storage": "Serverlager",
"backup_controller_page_start_backup": "Start sikkerhedskopiering",
"backup_controller_page_status_off": "Sikkerhedskopiering er slået fra",
"backup_controller_page_status_on": "Sikkerhedskopiering er slået til",
"backup_controller_page_storage_format": "{} af {} brugt",
"backup_controller_page_to_backup": "Albummer at sikkerhedskopiere",
"backup_controller_page_total": "I alt",
"backup_controller_page_total_sub": "Alle unikke billeder og videoer fra valgte albummer",
"backup_controller_page_turn_off": "Slå sikkerhedskopiering fra",
"backup_controller_page_turn_on": "Slå sikkerhedskopiering til",
"backup_controller_page_uploading_file_info": "Uploader filinformation",
"backup_err_only_album": "Kan ikke slette det eneste album",
"backup_info_card_assets": "elementer",
"control_bottom_app_bar_delete": "Slet",
"create_shared_album_page_share": "Del",
"create_shared_album_page_share_add_assets": "TILFØJ ELEMENT",
"create_shared_album_page_share_select_photos": "Vælg billeder",
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM, yyyy",
"date_format": "E d. LLL y • hh:mm",
"delete_dialog_alert": "Disse elementer vil blive slettet permanent fra Immich og din enhed",
"delete_dialog_cancel": "Annuller",
"delete_dialog_ok": "Slet",
"delete_dialog_title": "Slet permanent",
"exif_bottom_sheet_description": "Tilføj beskrivelse...",
"exif_bottom_sheet_details": "DETALJER",
"exif_bottom_sheet_location": "LOKATION",
"login_form_button_text": "Log ind",
"login_form_email_hint": "din-email@email.com",
"login_form_endpoint_hint": "http://din-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Angiv venligst http:// eller https://",
"login_form_err_invalid_email": "Ugyldig email",
"login_form_err_leading_whitespace": "Mellemrum før",
"login_form_err_trailing_whitespace": "Mellemrum efter",
"login_form_failed_login": "Der opstod en vejl ved at logge ind. Tjek server URL, email og kodeordet",
"login_form_label_email": "Email",
"login_form_label_password": "Kodeord",
"login_form_password_hint": "kodeord",
"login_form_save_login": "Forbliv logget ind",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Klient og server er ajour",
"profile_drawer_sign_out": "Log ud",
"search_bar_hint": "Søg i dine billeder",
"search_page_no_objects": "Ingen elementer er tilgængelige",
"search_page_no_places": "Ingen placeringsinformation er tilgængelig",
"search_page_places": "Steder",
"search_page_things": "Ting",
"search_result_page_new_search_hint": "Ny søgning",
"select_additional_user_for_sharing_page_suggestions": "Anbefalinger",
"select_user_for_sharing_page_err_album": "Fejlede i at oprette et nyt album",
"select_user_for_sharing_page_share_suggestions": "Anbefalinger",
"share_add": "Tilføj",
"share_add_photos": "Tilføj billeder",
"share_add_title": "Tilføj en titel",
"share_create_album": "Opret album",
"share_invite": "Inviter til album",
"sharing_page_album": "Delt albums",
"sharing_page_description": "Opret delte albummer for at dele billeder og video med personer på dit netværk.",
"sharing_page_empty_list": "TOM LISTE",
"sharing_silver_appbar_create_shared_album": "Opret delt album",
"sharing_silver_appbar_share_partner": "Del med partner",
"tab_controller_nav_photos": "Billeder",
"tab_controller_nav_search": "Søg",
"tab_controller_nav_sharing": "Deling",
"version_announcement_overlay_ack": "Vedkend",
"version_announcement_overlay_release_notes": "udgivelsesnoter",
"version_announcement_overlay_text_1": "Hej vej, der er en ny version af",
"version_announcement_overlay_text_2": "bresøg venligst ",
"version_announcement_overlay_text_3": " og sikker dig, at din dockercompose og .env-fil er opdateret, for at undgå fejlkonfiguration, specielt hvis u bruger WatchTowereller andre mekanisme, der automatisk opdaterer serverprogrammer.",
"version_announcement_overlay_title": "Ny serverversion er tilgængelig \uD83C\uDF89"
}

View File

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "AUSGESCHLOSSEN",
"album_info_card_backup_album_included": "EINGESCHLOSSEN",
"album_viewer_appbar_share_delete": "Album löschen",
"album_viewer_appbar_share_err_delete": "Album konnte nicht gelöscht werden",
"album_viewer_appbar_share_err_leave": "Album konnte nicht verlassen werden",
"album_viewer_appbar_share_err_remove": "Beim Löschen von Elementen aus dem Album ist ein Problem aufgetreten",
"album_viewer_appbar_share_err_title": "Der Titel konnte nicht geändert werden",
"album_viewer_appbar_share_leave": "Album verlassen",
"album_viewer_appbar_share_remove": "Entferne vom Album",
"album_viewer_page_share_add_users": "Nutzer hinzufügen",
"backup_album_selection_page_albums_device": "Alben auf dem Gerät ({})",
"backup_album_selection_page_albums_tap": "Tippen um einzuschließen, doppelt tippen um zu entfernen",
"backup_album_selection_page_assets_scatter": "Elemente können sich über mehrere Alben verteilen. Daher können diese vor der Sicherung eingeschlossen oder ausgeschlossen werden",
"backup_album_selection_page_select_albums": "Alben auswählen",
"backup_album_selection_page_selection_info": "Auswahl",
"backup_album_selection_page_total_assets": "Elemente",
"backup_all": "Alle",
"backup_controller_page_albums": "Gesicherte Alben",
"backup_controller_page_backup": "Sicherung",
"backup_controller_page_backup_selected": "Ausgewählt: ",
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
"backup_controller_page_cancel": "Abbrechen",
"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: ",
"backup_controller_page_failed": "Fehlgeschlagen ({})",
"backup_controller_page_filename": "Dateiname: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Informationen zur Sicherung",
"backup_controller_page_none_selected": "Keine ausgewählt",
"backup_controller_page_remainder": "Übrig",
"backup_controller_page_remainder_sub": "Noch zu sichernde Fotos und Videos",
"backup_controller_page_select": "Auswählen",
"backup_controller_page_server_storage": "Server Speicher",
"backup_controller_page_start_backup": "Sicherung starten",
"backup_controller_page_status_off": "Sicherung ist inaktiv",
"backup_controller_page_status_on": "Sicherung ist aktiv",
"backup_controller_page_storage_format": "{} von {} genutzt",
"backup_controller_page_to_backup": "Zu sichernde Alben",
"backup_controller_page_total": "Gesamt",
"backup_controller_page_total_sub": "Alle Fotos und Videos",
"backup_controller_page_turn_off": "Sicherung ausschalten",
"backup_controller_page_turn_on": "Sicherung einschalten",
"backup_controller_page_uploading_file_info": "Informationen",
"backup_err_only_album": "Das einzige Album kann nicht entfernt werden",
"backup_info_card_assets": "Elemente",
"control_bottom_app_bar_delete": "Löschen",
"create_shared_album_page_share": "Teilen",
"create_shared_album_page_share_add_assets": "ELEMENTE HINZUFÜGEN",
"create_shared_album_page_share_select_photos": "Fotos auswählen",
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM, yyyy",
"date_format": "E d. LLL y • hh:mm",
"delete_dialog_alert": "Diese Elemente werden unwiderruflich von Immich und dem Gerät entfernt",
"delete_dialog_cancel": "Abbrechen",
"delete_dialog_ok": "Löschen",
"delete_dialog_title": "Für immer löschen",
"exif_bottom_sheet_description": "Beschreibung hinzufügen...",
"exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "STANDORT",
"login_form_button_text": "Anmelden",
"login_form_email_hint": "deine@email.de",
"login_form_endpoint_hint": "http://deine-server-ip:port/api",
"login_form_endpoint_url": "Server URL",
"login_form_err_http": "Bitte gebe http:// oder https:// an",
"login_form_err_invalid_email": "Ungültige E-Mail",
"login_form_err_leading_whitespace": "Führendes Leerzichen",
"login_form_err_trailing_whitespace": "Folgendes Leerzeichen",
"login_form_failed_login": "Error logging you in, check server url, email and password",
"login_form_label_email": "E-Mail",
"login_form_label_password": "Passwort",
"login_form_password_hint": "password",
"login_form_save_login": "Angemeldet bleiben",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "App und Server sind aktuell",
"profile_drawer_sign_out": "Abmelden",
"search_bar_hint": "Durchsuche deine Fotos",
"search_page_no_objects": "Keine Objektinformationen verfügbar",
"search_page_no_places": "Keine Informationen über Orte verfügbar",
"search_page_places": "Orte",
"search_page_things": "Dinge",
"search_result_page_new_search_hint": "Neue Suche",
"select_additional_user_for_sharing_page_suggestions": "Vorschläge",
"select_user_for_sharing_page_err_album": "Album konnte nicht erstellt werden",
"select_user_for_sharing_page_share_suggestions": "Suggestions",
"share_add": "Hinzufügen",
"share_add_photos": "Fotos hinzufügen",
"share_add_title": "Titel hinzufügen",
"share_create_album": "Album erstellen",
"share_invite": "Zum Album einladen",
"sharing_page_album": "Geteilte Alben",
"sharing_page_description": "Erstelle ein geteiltes Album um Fotos und Videos mit Personen in deinem Netzwerk zu teilen.",
"sharing_page_empty_list": "LEERE LISTE",
"sharing_silver_appbar_create_shared_album": "Neues geteiltes Album",
"sharing_silver_appbar_share_partner": "Teile mit Partner",
"tab_controller_nav_photos": "Fotos",
"tab_controller_nav_search": "Suche",
"tab_controller_nav_sharing": "Teilen",
"version_announcement_overlay_ack": "Ich habe verstanden",
"version_announcement_overlay_release_notes": "Änderungsprotokoll",
"version_announcement_overlay_text_1": "Hallo mein Freund! Es gibt eine neue Version von",
"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

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "EXCLUDED",
"album_info_card_backup_album_included": "INCLUDED",
"album_viewer_appbar_share_delete": "Delete album",
"album_viewer_appbar_share_err_delete": "Failed to delete album",
"album_viewer_appbar_share_err_leave": "Failed to leave album",
"album_viewer_appbar_share_err_remove": "There are problems in removing assets from album",
"album_viewer_appbar_share_err_title": "Failed to change album title",
"album_viewer_appbar_share_leave": "Leave album",
"album_viewer_appbar_share_remove": "Remove from album",
"album_viewer_page_share_add_users": "Add users",
"backup_album_selection_page_albums_device": "Albums on device ({})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
"backup_album_selection_page_select_albums": "Select Albums",
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
"backup_all": "All",
"backup_controller_page_albums": "Backup Albums",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Selected: ",
"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_excluded": "Excluded: ",
"backup_controller_page_failed": "Failed ({})",
"backup_controller_page_filename": "File name: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Backup Information",
"backup_controller_page_none_selected": "None selected",
"backup_controller_page_remainder": "Remainder",
"backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection",
"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_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_uploading_file_info": "Uploading file info",
"backup_err_only_album": "Cannot remove the only album",
"backup_info_card_assets": "assets",
"control_bottom_app_bar_delete": "Delete",
"create_shared_album_page_share": "Share",
"create_shared_album_page_share_add_assets": "ADD ASSETS",
"create_shared_album_page_share_select_photos": "Select Photos",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"date_format": "E, LLL d, y • h:mm a",
"delete_dialog_alert": "These items will be permanently deleted from Immich and from your device",
"delete_dialog_cancel": "Cancel",
"delete_dialog_ok": "Delete",
"delete_dialog_title": "Delete Permanently",
"exif_bottom_sheet_description": "Add Description...",
"exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "LOCATION",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Please specify http:// or https://",
"login_form_err_invalid_email": "Invalid Email",
"login_form_err_leading_whitespace": "Leading whitespace",
"login_form_err_trailing_whitespace": "Trailing whitespace",
"login_form_failed_login": "Error logging you in, check server url, email and password",
"login_form_label_email": "Email",
"login_form_label_password": "Password",
"login_form_password_hint": "password",
"login_form_save_login": "Stay logged in",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
"profile_drawer_sign_out": "Sign Out",
"search_bar_hint": "Search your photos",
"search_page_no_objects": "No Objects Info Available",
"search_page_no_places": "No Places Info Available",
"search_page_places": "Places",
"search_page_things": "Things",
"search_result_page_new_search_hint": "New Search",
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
"select_user_for_sharing_page_err_album": "Failed to create album",
"select_user_for_sharing_page_share_suggestions": "Suggestions",
"share_add": "Add",
"share_add_photos": "Add photos",
"share_add_title": "Add a title",
"share_create_album": "Create album",
"share_invite": "Invite to album",
"sharing_page_album": "Shared albums",
"sharing_page_description": "Create shared albums to share photos and videos with people in your network.",
"sharing_page_empty_list": "EMPTY LIST",
"sharing_silver_appbar_create_shared_album": "Create shared album",
"sharing_silver_appbar_share_partner": "Share with partner",
"tab_controller_nav_photos": "Photos",
"tab_controller_nav_search": "Search",
"tab_controller_nav_sharing": "Sharing",
"version_announcement_overlay_ack": "Acknowledge",
"version_announcement_overlay_release_notes": "release notes",
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
"version_announcement_overlay_text_2": "please take your time to visit the ",
"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"
}

View File

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "EXCLUIDOS",
"album_info_card_backup_album_included": "INCLUIDOS",
"album_viewer_appbar_share_delete": "Eliminar álbum ",
"album_viewer_appbar_share_err_delete": "No ha podido eliminar el álbum",
"album_viewer_appbar_share_err_leave": "No ha podido dejar el álbum",
"album_viewer_appbar_share_err_remove": "Hay problemas para eliminar los activos del álbum",
"album_viewer_appbar_share_err_title": "Error al cambiar el título del álbum ",
"album_viewer_appbar_share_leave": "Abandonar álbum ",
"album_viewer_appbar_share_remove": "Eliminar del álbum ",
"album_viewer_page_share_add_users": "Añadir usuarios",
"backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})",
"backup_album_selection_page_albums_tap": "Toque para incluir, doble toque para excluir",
"backup_album_selection_page_assets_scatter": "Los activos pueden dispersarse en varios álbumes. De este modo, los álbumes pueden ser incluidos o excluidos durante el proceso de copia de seguridad.",
"backup_album_selection_page_select_albums": "Seleccionar Álbumes",
"backup_album_selection_page_selection_info": "Información sobre la Selección",
"backup_album_selection_page_total_assets": "Total de activos únicos",
"backup_all": "Todos",
"backup_controller_page_albums": "Álbumes de copia de seguridad",
"backup_controller_page_backup": "Copia de Seguridad",
"backup_controller_page_backup_selected": "Seleccionado:",
"backup_controller_page_backup_sub": "Copia de seguridad de fotos y vídeos",
"backup_controller_page_cancel": "Cancelar",
"backup_controller_page_created": "",
"backup_controller_page_desc_backup": "Active la copia de seguridad para cargar automáticamente los nuevos activos al servidor.",
"backup_controller_page_excluded": "Excluido:",
"backup_controller_page_failed": "",
"backup_controller_page_filename": "",
"backup_controller_page_id": "",
"backup_controller_page_info": "Información de la Copia de Seguridad",
"backup_controller_page_none_selected": "Ninguno seleccionado",
"backup_controller_page_remainder": "Remanente",
"backup_controller_page_remainder_sub": "Fotos y álbumes restantes para hacer una copia de seguridad de la selección",
"backup_controller_page_select": "Seleccionar",
"backup_controller_page_server_storage": "Almacenamiento en el servidor",
"backup_controller_page_start_backup": "Iniciar copia de seguridad",
"backup_controller_page_status_off": "La copia de seguridad está desactivada",
"backup_controller_page_status_on": "La copia de seguridad está activada",
"backup_controller_page_storage_format": "{} de {} usadas",
"backup_controller_page_to_backup": "Álbumes a respaldar",
"backup_controller_page_total": "Total",
"backup_controller_page_total_sub": "Todas las fotos y vídeos únicos de los álbumes seleccionados",
"backup_controller_page_turn_off": "Apagar la copia de seguridad",
"backup_controller_page_turn_on": "Activar la copia de seguridad",
"backup_controller_page_uploading_file_info": "",
"backup_err_only_album": "No se puede eliminar el único álbum",
"backup_info_card_assets": "activos",
"control_bottom_app_bar_delete": "Eliminar",
"create_shared_album_page_share": "Compartir",
"create_shared_album_page_share_add_assets": "AÑADIR ACTIVOS",
"create_shared_album_page_share_select_photos": "Seleccionar Fotos",
"daily_title_text_date": "E dd, MMM",
"daily_title_text_date_year": "E dd de MMM, yyyy",
"date_format": "E d, LLL y • h:mm a",
"delete_dialog_alert": "Estos elementos serán eliminados permanentemente de Immich y de tu dispositivo",
"delete_dialog_cancel": "Cancelar",
"delete_dialog_ok": "Eliminar",
"delete_dialog_title": "Eliminar Permanentemente",
"exif_bottom_sheet_description": "Añadir Descripción...",
"exif_bottom_sheet_details": "DETALLES",
"exif_bottom_sheet_location": "LOCALZACIÓN",
"login_form_button_text": "Iniciar Sesión",
"login_form_email_hint": "tucorreo@correo.com",
"login_form_endpoint_hint": "http://tu-ip-de-servidor:puerto/api",
"login_form_endpoint_url": "URL del servidor",
"login_form_err_http": "Por favor, especifique http:// o https://",
"login_form_err_invalid_email": "Correo electrónico no válido",
"login_form_err_leading_whitespace": "Espacio en blanco inicial",
"login_form_err_trailing_whitespace": "Espacio en blanco al final",
"login_form_failed_login": "",
"login_form_label_email": "Correo",
"login_form_label_password": "Contraseña",
"login_form_password_hint": "contraseña",
"login_form_save_login": "Mantener la sesión iniciada",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "El Cliente y el Servidor están actualizados",
"profile_drawer_sign_out": "Cerrar Sesión",
"search_bar_hint": "Busca tus fotos",
"search_page_no_objects": "",
"search_page_no_places": "No hay información de lugares disponibles",
"search_page_places": "Lugares",
"search_page_things": "Cosas",
"search_result_page_new_search_hint": "Nueva Busqueda",
"select_additional_user_for_sharing_page_suggestions": "Sugerencias",
"select_user_for_sharing_page_err_album": "Fallo al crear el álbum",
"select_user_for_sharing_page_share_suggestions": "",
"share_add": "Añadir",
"share_add_photos": "Añadir fotos",
"share_add_title": "Añadir un título",
"share_create_album": "Crear álbum",
"share_invite": "Invitar al álbum",
"sharing_page_album": "Álbumes compartidos",
"sharing_page_description": "Crea álbumes compartidos para compartir fotos y vídeos con las personas de tu red.",
"sharing_page_empty_list": "LISTA VACIA",
"sharing_silver_appbar_create_shared_album": "Crear un álbum compartido",
"sharing_silver_appbar_share_partner": "Compartir con el compañero",
"tab_controller_nav_photos": "Fotos",
"tab_controller_nav_search": "Buscar",
"tab_controller_nav_sharing": "Compartiendo",
"version_announcement_overlay_ack": "Reconocer",
"version_announcement_overlay_release_notes": "notas de versión",
"version_announcement_overlay_text_1": "Hola amigo, hay una nueva versión de",
"version_announcement_overlay_text_2": "tómese su tiempo para visitar la ",
"version_announcement_overlay_text_3": "y asegurate de que tu configuración de docker-compose y .env está actualizada para evitar cualquier desconfiguración, especialmente si utiliza WatchTower o cualquier mecanismo que se encargue de actualizar su aplicación de servidor automáticamente.",
"version_announcement_overlay_title": "Nueva versión del servidor disponible \uD83C\uDF89"
}

View File

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "JÄTETTY POIS",
"album_info_card_backup_album_included": "SISÄLLYTETTY",
"album_viewer_appbar_share_delete": "Poista albumi",
"album_viewer_appbar_share_err_delete": "Albumin poistaminen epäonnistui",
"album_viewer_appbar_share_err_leave": "Albumista poistuminen epäonnistui",
"album_viewer_appbar_share_err_remove": "Ongelmia kohteiden poistamisessa albumista",
"album_viewer_appbar_share_err_title": "Albumin nimen muuttaminen epäonnistui",
"album_viewer_appbar_share_leave": "Poistu albumista",
"album_viewer_appbar_share_remove": "Poista albumista",
"album_viewer_page_share_add_users": "Lisää käyttäjiä",
"backup_album_selection_page_albums_device": "Laitteen albumit ({})",
"backup_album_selection_page_albums_tap": "Napauta sisällyttääksesi, kaksoisnapauta jättääksesi pois",
"backup_album_selection_page_assets_scatter": "Kohteet voivat olla hajaantuneina useisiin albumeihin. Albumeita voidaan sisällyttää varmuuskopiointiin tai jättää siitä pois.",
"backup_album_selection_page_select_albums": "Valitse albumit",
"backup_album_selection_page_selection_info": "Valintatiedot",
"backup_album_selection_page_total_assets": "Uniikkeja kohteita yhteensä",
"backup_all": "Kaikki",
"backup_controller_page_albums": "Varmuuskopioi albumit",
"backup_controller_page_backup": "Varmuuskopioitu",
"backup_controller_page_backup_selected": "Valittu:",
"backup_controller_page_backup_sub": "Varmuuskopioidut kuvat ja videot",
"backup_controller_page_cancel": "Peruuta",
"backup_controller_page_created": "Luotu: {}",
"backup_controller_page_desc_backup": "Kytke varmuuskopiointi päälle ladataksesi uudet kohteet palvelimelle automaattisesti.",
"backup_controller_page_excluded": "Jätetty pois:",
"backup_controller_page_failed": "Epäonnistui ({})",
"backup_controller_page_filename": "Tiedoston nimi: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Varmuuskopioinnin tiedot",
"backup_controller_page_none_selected": "Ei mitään",
"backup_controller_page_remainder": "Jäljellä",
"backup_controller_page_remainder_sub": "Varmuuskopiointia odottavat kuvat ja videot",
"backup_controller_page_select": "Valitse",
"backup_controller_page_server_storage": "Palvelimen tallennustila",
"backup_controller_page_start_backup": "Aloita varmuuskopiointi",
"backup_controller_page_status_off": "Varmuuskopiointi on pois päältä",
"backup_controller_page_status_on": "Varmuuskopiointi on päällä",
"backup_controller_page_storage_format": "{} / {} käytetty",
"backup_controller_page_to_backup": "Varmuuskopioitavat albumit",
"backup_controller_page_total": "Yhteensä",
"backup_controller_page_total_sub": "Kaikki uniikit kuvat ja videot valituista albumeista",
"backup_controller_page_turn_off": "Varmuuskopiointi pois päältä",
"backup_controller_page_turn_on": "Varmuuskopiointi päälle",
"backup_controller_page_uploading_file_info": "Tiedostojen lähetystiedot",
"backup_err_only_album": "Vähintään yhden albumin tulee olla valittuna",
"backup_info_card_assets": "kohdetta",
"control_bottom_app_bar_delete": "Poista",
"create_shared_album_page_share": "Jaa",
"create_shared_album_page_share_add_assets": "LISÄÄ KOHTEITA",
"create_shared_album_page_share_select_photos": "Valitse kuvat",
"daily_title_text_date": "",
"daily_title_text_date_year": "",
"date_format": "",
"delete_dialog_alert": "Nämä kohteet poistetaan pysyvästi Immich:stä ja laitteeltasi",
"delete_dialog_cancel": "Peruuta",
"delete_dialog_ok": "Poista",
"delete_dialog_title": "Poista pysyvästi",
"exif_bottom_sheet_description": "Lisää kuvaus…",
"exif_bottom_sheet_details": "TIEDOT",
"exif_bottom_sheet_location": "SIJAINTI",
"login_form_button_text": "Kirjaudu",
"login_form_email_hint": "sahkopostisi@esimerkki.fi",
"login_form_endpoint_hint": "http://palvelimesi-osoite:portti/api",
"login_form_endpoint_url": "Palvelimen URL",
"login_form_err_http": "Lisää http:// tai https://",
"login_form_err_invalid_email": "Virheellinen sähköpostiosoite",
"login_form_err_leading_whitespace": "Alussa välilyönti",
"login_form_err_trailing_whitespace": "Lopussa välilyönti",
"login_form_failed_login": "Virhe kirjautumisessa. Tarkista palvelimen URL, sähköpostiosoite ja salasana.",
"login_form_label_email": "Sähköposti",
"login_form_label_password": "Salasana",
"login_form_password_hint": "salasana",
"login_form_save_login": "Pysy kirjautuneena",
"monthly_title_text_date_format": "",
"profile_drawer_client_server_up_to_date": "Asiakassovellus ja palvelin ovat ajan tasalla",
"profile_drawer_sign_out": "Kirjaudu ulos",
"search_bar_hint": "Etsi kuvia",
"search_page_no_objects": "Objektitietoja ei ole saatavilla",
"search_page_no_places": "Paikkatietoja ei ole saatavilla",
"search_page_places": "Paikat",
"search_page_things": "Asiat",
"search_result_page_new_search_hint": "Uusi haku",
"select_additional_user_for_sharing_page_suggestions": "Ehdotukset",
"select_user_for_sharing_page_err_album": "Albumin luonti epäonnistui",
"select_user_for_sharing_page_share_suggestions": "Ehdotukset",
"share_add": "Lisää",
"share_add_photos": "Lisää kuvia",
"share_add_title": "Lisää nimi",
"share_create_album": "Luo albumi",
"share_invite": "Kutsu albumiin",
"sharing_page_album": "Jaetut albumit",
"sharing_page_description": "Luo jaettuja albumeja jakaaksesi kuvia ja videoita läheisillesi.",
"sharing_page_empty_list": "TYHJÄ LISTA",
"sharing_silver_appbar_create_shared_album": "Luo jaettu albumi",
"sharing_silver_appbar_share_partner": "Jaa kumppanille",
"tab_controller_nav_photos": "Kuvat",
"tab_controller_nav_search": "Haku",
"tab_controller_nav_sharing": "Jakaminen",
"version_announcement_overlay_ack": "Tiedostan",
"version_announcement_overlay_release_notes": "julkaisutiedoissa",
"version_announcement_overlay_text_1": "Hei, kaveri! Uusi palvelinversio on saatavilla sovelluksesta",
"version_announcement_overlay_text_2": "Ota hetki aikaa vieraillaksesi",
"version_announcement_overlay_text_3": "ja varmista, että käyttämäsi docker-compose ja .env-asetukset ovat ajantasalla välttyäksesi asetusongelmilta. Varsinkin jos käytät WatchToweria tai jotain muuta mekanismia päivittääksesi palvelinsovellusta automaattisesti.",
"version_announcement_overlay_title": "Uusi palvelinversio saatavilla \uD83C\uDF89"
}

View File

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "EXCLU",
"album_info_card_backup_album_included": "INCLUS",
"album_viewer_appbar_share_delete": "Supprimer l'album",
"album_viewer_appbar_share_err_delete": "Échec de la suppression de l'album",
"album_viewer_appbar_share_err_leave": "Impossible de quitter l'album",
"album_viewer_appbar_share_err_remove": "Il y a des problèmes pour retirer les éléments de l'album",
"album_viewer_appbar_share_err_title": "Échec de la modification du titre de l'album",
"album_viewer_appbar_share_leave": "Quitter l'album",
"album_viewer_appbar_share_remove": "Retirer de l'album",
"album_viewer_page_share_add_users": "Ajouter des utilisateurs",
"backup_album_selection_page_albums_device": "Albums sur l'appareil ({})",
"backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure",
"backup_album_selection_page_assets_scatter": "Les éléments peuvent être répartis sur plusieurs albums. De ce fait, les albums peuvent être inclus ou exclus pendant le processus de sauvegarde.",
"backup_album_selection_page_select_albums": "Sélectionner les albums",
"backup_album_selection_page_selection_info": "Informations sur la sélection",
"backup_album_selection_page_total_assets": "Total des éléments uniques",
"backup_all": "Tout",
"backup_controller_page_albums": "Sauvegarder les albums",
"backup_controller_page_backup": "Sauvegardé",
"backup_controller_page_backup_selected": "Sélectionné : ",
"backup_controller_page_backup_sub": "Photos et vidéos sauvegardées",
"backup_controller_page_cancel": "Annuler",
"backup_controller_page_created": "Créé le : {}",
"backup_controller_page_desc_backup": "Activez la sauvegarde pour envoyer automatiquement les nouveaux éléments sur le serveur.",
"backup_controller_page_excluded": "Exclus : ",
"backup_controller_page_failed": "Échec de l'opération ({})",
"backup_controller_page_filename": "Nom du fichier : {} [{}]",
"backup_controller_page_id": "ID : {}",
"backup_controller_page_info": "Informations de sauvegarde",
"backup_controller_page_none_selected": "Aucune sélection",
"backup_controller_page_remainder": "Restant",
"backup_controller_page_remainder_sub": "Photos et albums restants à sauvegarder à partir de la sélection",
"backup_controller_page_select": "Sélectionner",
"backup_controller_page_server_storage": "Stockage du serveur",
"backup_controller_page_start_backup": "Démarrer la sauvegarde",
"backup_controller_page_status_off": "La sauvegarde est désactivée",
"backup_controller_page_status_on": "La sauvegarde est activée",
"backup_controller_page_storage_format": "{} de {} utilisé",
"backup_controller_page_to_backup": "Albums à sauvegarder",
"backup_controller_page_total": "Total",
"backup_controller_page_total_sub": "Toutes les photos et vidéos uniques des albums sélectionnés",
"backup_controller_page_turn_off": "Désactiver la sauvegarde",
"backup_controller_page_turn_on": "Activer la sauvegarde",
"backup_controller_page_uploading_file_info": "Envoi d'informations sur le fichier",
"backup_err_only_album": "Impossible de retirer le seul album",
"backup_info_card_assets": "éléments",
"control_bottom_app_bar_delete": "Supprimer",
"create_shared_album_page_share": "Partager",
"create_shared_album_page_share_add_assets": "AJOUTER DES ÉLÉMENTS",
"create_shared_album_page_share_select_photos": "Sélectionner les photos",
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM, yyyy",
"date_format": "E, LLL d, y • h:mm a",
"delete_dialog_alert": "Ces éléments seront définitivement supprimés de Immich et de votre appareil.",
"delete_dialog_cancel": "Annuler",
"delete_dialog_ok": "Supprimer",
"delete_dialog_title": "Supprimer définitivement",
"exif_bottom_sheet_description": "Ajouter une description...",
"exif_bottom_sheet_details": "DÉTAILS",
"exif_bottom_sheet_location": "LOCALISATION",
"login_form_button_text": "Connexion",
"login_form_email_hint": "votreemail@email.com",
"login_form_endpoint_hint": "http://adresse-ip-serveur:port/api",
"login_form_endpoint_url": "URL du point d'accès au serveur",
"login_form_err_http": "Veuillez préciser http:// ou https://",
"login_form_err_invalid_email": "Email invalide",
"login_form_err_leading_whitespace": "Espace en début de ligne",
"login_form_err_trailing_whitespace": "Espace de fin de ligne",
"login_form_failed_login": "Erreur de connexion, vérifiez l'url du serveur, l'email et le mot de passe",
"login_form_label_email": "Email",
"login_form_label_password": "Mot de passe",
"login_form_password_hint": "mot de passe",
"login_form_save_login": "Rester connecté",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Le client et le serveur sont à jour",
"profile_drawer_sign_out": "Se déconnecter",
"search_bar_hint": "Rechercher vos photos",
"search_page_no_objects": "Aucune information disponible sur les objets",
"search_page_no_places": "Aucune information disponible sur la localisation",
"search_page_places": "Lieux",
"search_page_things": "Objets",
"search_result_page_new_search_hint": "Nouvelle recherche",
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
"select_user_for_sharing_page_err_album": "Échec de la création de l'album",
"select_user_for_sharing_page_share_suggestions": "Suggestions",
"share_add": "Ajouter",
"share_add_photos": "Ajouter des photos",
"share_add_title": "Ajouter un titre",
"share_create_album": "Créer un album",
"share_invite": "Inviter à l'album",
"sharing_page_album": "Albums partagés",
"sharing_page_description": "Créez des albums partagés pour partager des photos et des vidéos avec les personnes de votre réseau.",
"sharing_page_empty_list": "LISTE VIDE",
"sharing_silver_appbar_create_shared_album": "Créer un album partagé",
"sharing_silver_appbar_share_partner": "Partager avec un partenaire",
"tab_controller_nav_photos": "Photos",
"tab_controller_nav_search": "Recherche",
"tab_controller_nav_sharing": "Partage",
"version_announcement_overlay_ack": "Confirmer",
"version_announcement_overlay_release_notes": "notes de mise à jour",
"version_announcement_overlay_text_1": "Bonjour, une nouvelle version de",
"version_announcement_overlay_text_2": "veuillez prendre le temps de visiter le ",
"version_announcement_overlay_text_3": " et assurez-vous que votre configuration docker-compose et .env est à jour pour éviter toute erreur de configuration, en particulier si vous utilisez WatchTower ou tout autre mécanisme qui gère la mise à jour automatique de votre application serveur.",
"version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89"
}

View File

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "ESCLUSI",
"album_info_card_backup_album_included": "INCLUSI",
"album_viewer_appbar_share_delete": "Elimina album ",
"album_viewer_appbar_share_err_delete": "Fallito nel cancellare l'album ",
"album_viewer_appbar_share_err_leave": "Fallito nel lasciare l'album ",
"album_viewer_appbar_share_err_remove": "Ci sono problemi nel rimuovere oggetti dall'album ",
"album_viewer_appbar_share_err_title": "Fallito nel cambiare titolo dell'album ",
"album_viewer_appbar_share_leave": "Lascia l'album",
"album_viewer_appbar_share_remove": "Rimuovere dall'album ",
"album_viewer_page_share_add_users": "Aggiungi utenti",
"backup_album_selection_page_albums_device": "Albums nel device ({})",
"backup_album_selection_page_albums_tap": "Tap per includere, doppio tap per escludere.",
"backup_album_selection_page_assets_scatter": "Stesse immagini e video possono trovarsi tra più album, così gli album possono essere inclusi o esclusi dal backup.",
"backup_album_selection_page_select_albums": "Seleziona gli album",
"backup_album_selection_page_selection_info": "Informazioni sulla selezione ",
"backup_album_selection_page_total_assets": "Numero totale di oggetti unici",
"backup_all": "Tutti",
"backup_controller_page_albums": "Backup album",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Selezionati:",
"backup_controller_page_backup_sub": "Photo e video salvati",
"backup_controller_page_cancel": "Cancella ",
"backup_controller_page_created": "Creato il: {}",
"backup_controller_page_desc_backup": "Attiva il backup automatico per eseguire upload sul server",
"backup_controller_page_excluded": "Esclusi:",
"backup_controller_page_failed": "Falliti: ({})",
"backup_controller_page_filename": "Nome del file: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Informazioni sul backup",
"backup_controller_page_none_selected": "Nessuna selezione",
"backup_controller_page_remainder": "Promemoria ",
"backup_controller_page_remainder_sub": "Photo e album selezionati che rimangono da salvare",
"backup_controller_page_select": "Seleziona ",
"backup_controller_page_server_storage": "Spazio nel server",
"backup_controller_page_start_backup": "Inizia backup ",
"backup_controller_page_status_off": "Backup è disattivato ",
"backup_controller_page_status_on": "Backup è attivato",
"backup_controller_page_storage_format": "{} di {} usati",
"backup_controller_page_to_backup": "Album da salvare",
"backup_controller_page_total": "Totale",
"backup_controller_page_total_sub": "Tutte le foto e i video unici salvati dagli album selezionati ",
"backup_controller_page_turn_off": "Disattiva backup",
"backup_controller_page_turn_on": "Attiva backup ",
"backup_controller_page_uploading_file_info": "Info sul file caricato",
"backup_err_only_album": "Non è possibile rimuovere l'unico album",
"backup_info_card_assets": "Oggetti ",
"control_bottom_app_bar_delete": "Elimina",
"create_shared_album_page_share": "Condividi",
"create_shared_album_page_share_add_assets": "AGGIUNGI OGGETTI",
"create_shared_album_page_share_select_photos": "Seleziona foto",
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM, yyyy",
"date_format": "E, d LLL, y • hh:mm",
"delete_dialog_alert": "Questi oggetti saranno cancellati permanentemente da Immich e dal tuo device",
"delete_dialog_cancel": "Annulla",
"delete_dialog_ok": "Elimina",
"delete_dialog_title": "Cancella in modo permanente ",
"exif_bottom_sheet_description": "Aggiungi una descrizione...",
"exif_bottom_sheet_details": "DETTAGLI",
"exif_bottom_sheet_location": "POSIZIONE",
"login_form_button_text": "Accedi",
"login_form_email_hint": "tuaemail@email.com",
"login_form_endpoint_hint": "http://tuo-ip-del-server:port/api",
"login_form_endpoint_url": "URL del Server Endpoint",
"login_form_err_http": "Per favore specificare http:// o https://",
"login_form_err_invalid_email": "Email non valida",
"login_form_err_leading_whitespace": "Spazio bianco all'inizio ",
"login_form_err_trailing_whitespace": "Spazio bianco alla fine",
"login_form_failed_login": "Errore nel login, controlla URL del server e le credenziali (email e password)",
"login_form_label_email": "Email",
"login_form_label_password": "Password",
"login_form_password_hint": "password ",
"login_form_save_login": "Rimani connesso ",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Client e server sono aggiornati",
"profile_drawer_sign_out": "Esci",
"search_bar_hint": "Cerca le tue foto",
"search_page_no_objects": "Nessuna Informazione relativa all'Oggetto Disponibile",
"search_page_no_places": "Nessun informazione sulla posizione ",
"search_page_places": "Luoghi",
"search_page_things": "Oggetti",
"search_result_page_new_search_hint": "Nuova ricerca ",
"select_additional_user_for_sharing_page_suggestions": "Suggerimenti ",
"select_user_for_sharing_page_err_album": "Fallito nel creare l'album ",
"select_user_for_sharing_page_share_suggestions": "Suggerimenti",
"share_add": "Aggiungi",
"share_add_photos": "Aggiungi foto",
"share_add_title": "Aggiungi un titolo ",
"share_create_album": "Crea album",
"share_invite": "Invitare all'album ",
"sharing_page_album": "Album condivisi",
"sharing_page_description": "Crea un album condiviso per condividere foto e video con gente nel tuo network",
"sharing_page_empty_list": "LISTA VUOTA",
"sharing_silver_appbar_create_shared_album": "Crea album condiviso",
"sharing_silver_appbar_share_partner": "Condividi con il partner",
"tab_controller_nav_photos": "Foto",
"tab_controller_nav_search": "Cerca",
"tab_controller_nav_sharing": "Condividi",
"version_announcement_overlay_ack": "Riconosci ",
"version_announcement_overlay_release_notes": "le note di rilascio ",
"version_announcement_overlay_text_1": "Ciao amico, c'è una nuova versione di",
"version_announcement_overlay_text_2": "prova a controllare ",
"version_announcement_overlay_text_3": "e verifica che il tuo docker-compose e il file .env siano aggiornati per impedire qualsiasi errore nella configurazione, specialmente se utilizzate WatchTower o altri strumenti per l'aggiornamento automatico delle immagini docker.",
"version_announcement_overlay_title": "Nuova versione di server disponibile! \uD83C\uDF89"
}

View File

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "除外",
"album_info_card_backup_album_included": "選択",
"album_viewer_appbar_share_delete": "アルバムを削除",
"album_viewer_appbar_share_err_delete": "削除に失敗...",
"album_viewer_appbar_share_err_leave": "退会に失敗...",
"album_viewer_appbar_share_err_remove": "アルバムから写真を除外する際にエラー発生",
"album_viewer_appbar_share_err_title": "タイトルの変更に失敗...",
"album_viewer_appbar_share_leave": "アルバムから退会",
"album_viewer_appbar_share_remove": "アルバムから除外",
"album_viewer_page_share_add_users": "ユーザーを追加",
"backup_album_selection_page_albums_device": "端末上のアルバム数は {} だよ",
"backup_album_selection_page_albums_tap": "タップで選択、ダブルタップで除外だよ",
"backup_album_selection_page_assets_scatter": "写真がいろんなアルバムに登録されてる事があるから、アルバムを含めたり除外したりしてどの写真を保存するか選択できるよ。",
"backup_album_selection_page_select_albums": "アルバムを選択",
"backup_album_selection_page_selection_info": "選択、又は除外されてるアルバム",
"backup_album_selection_page_total_assets": "選択されたアルバムの写真と動画の数",
"backup_all": "全て",
"backup_controller_page_albums": "アルバム",
"backup_controller_page_backup": "バックアップ",
"backup_controller_page_backup_selected": "選択されてる:",
"backup_controller_page_backup_sub": "バックアップされた写真と動画の数だよ",
"backup_controller_page_cancel": "キャンセルするよ",
"backup_controller_page_created": "{} に作成されたよ",
"backup_controller_page_desc_backup": "ONにすれば自動的に新しい写真などがバックアップされるようになるよ",
"backup_controller_page_excluded": "除外されてるアルバム:",
"backup_controller_page_failed": "失敗: ({})",
"backup_controller_page_filename": "ファイル名: {} [{}] ",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "バックアップ情報",
"backup_controller_page_none_selected": "何も選んでないよ",
"backup_controller_page_remainder": "リマインダー",
"backup_controller_page_remainder_sub": "残りの写真と動画の数だよ",
"backup_controller_page_select": "選択",
"backup_controller_page_server_storage": "サーバーの容量",
"backup_controller_page_start_backup": "バックアップを開始するよ",
"backup_controller_page_status_off": "バックアップがOFFになってるよ",
"backup_controller_page_status_on": "バックアップがONになってるよ",
"backup_controller_page_storage_format": "{}中、 {}を使用中だよ",
"backup_controller_page_to_backup": "バックアップされるアルバム",
"backup_controller_page_total": "トータル",
"backup_controller_page_total_sub": "選択されたアルバムの写真と動画の数だよ",
"backup_controller_page_turn_off": "バックアップOFF",
"backup_controller_page_turn_on": "バックアップON",
"backup_controller_page_uploading_file_info": "アップロードされてるファイルに関する情報",
"backup_err_only_album": "唯一のアルバムを除外する事はできないよ",
"backup_info_card_assets": "写真と動画",
"control_bottom_app_bar_delete": "削除",
"create_shared_album_page_share": "共有",
"create_shared_album_page_share_add_assets": "写真や動画を追加",
"create_shared_album_page_share_select_photos": "写真を選択",
"daily_title_text_date": "E, MM月 dd日",
"daily_title_text_date_year": "E, yyyy年 MM月 dd日",
"date_format": "E, MM月 dd日 • hh時mm分",
"delete_dialog_alert": "サーバーからも端末からも永久的に削除されるけど良いの?",
"delete_dialog_cancel": "キャンセル",
"delete_dialog_ok": "削除",
"delete_dialog_title": "永久的に削除",
"exif_bottom_sheet_description": "概要を追加",
"exif_bottom_sheet_details": "詳細な情報",
"exif_bottom_sheet_location": "撮影地",
"login_form_button_text": "ログイン",
"login_form_email_hint": "example@email.com",
"login_form_endpoint_hint": "https://example.com:port/api",
"login_form_endpoint_url": "サーバーエンドポイントURL",
"login_form_err_http": "http://かhttps://かを指定してね",
"login_form_err_invalid_email": "メールアドレスが有効じゃないよ",
"login_form_err_leading_whitespace": "最初に半角スペースが含まれてるよ",
"login_form_err_trailing_whitespace": "最後に半角スペースが含まれてるよ",
"login_form_failed_login": "ログインエラー。サーバーのURL、メールアドレスとパスワードを再確認してね",
"login_form_label_email": "メールアドレス",
"login_form_label_password": "パスワード",
"login_form_password_hint": "パスワード",
"login_form_save_login": "ログインしたままにする",
"monthly_title_text_date_format": "yyyy年 MM月",
"profile_drawer_client_server_up_to_date": "サーバーとクライアント、両方最新バージョンだよ",
"profile_drawer_sign_out": "サインアウト",
"search_bar_hint": "写真を検索",
"search_page_no_objects": "被写体に関するデータがないよ",
"search_page_no_places": "場所に関するデータがないよ",
"search_page_places": "撮影地",
"search_page_things": "カテゴリ",
"search_result_page_new_search_hint": "検索",
"select_additional_user_for_sharing_page_suggestions": "ユーザーリスト",
"select_user_for_sharing_page_err_album": "アルバム作成に失敗...",
"select_user_for_sharing_page_share_suggestions": "ユーザーの一覧だよ",
"share_add": "追加",
"share_add_photos": "写真を追加",
"share_add_title": "タイトルを追加",
"share_create_album": "アルバムを作成",
"share_invite": "アルバムに参加",
"sharing_page_album": "共有アルバム",
"sharing_page_description": "共有アルバムを作成して同じネットワークにいる仲間に写真を共有してみよう!",
"sharing_page_empty_list": "誰も居ないね ( T_T)(^-^ ) ドンマイ",
"sharing_silver_appbar_create_shared_album": "共有アルバムを作成",
"sharing_silver_appbar_share_partner": "パートナーと共有",
"tab_controller_nav_photos": "写真",
"tab_controller_nav_search": "検索",
"tab_controller_nav_sharing": "共有",
"version_announcement_overlay_ack": "了解",
"version_announcement_overlay_release_notes": "更新情報",
"version_announcement_overlay_text_1": "こんにちは、又はこんばんは!新しい",
"version_announcement_overlay_text_2": "のバージョンが公開中だよ。",
"version_announcement_overlay_text_3": "を確認してみてね。あと、docker-composeや.envファイルが最新の状態に更新されてか、特にWatchTowerなどのツールを使ってDockerイメージを自動アップデートしてる人は確認してね",
"version_announcement_overlay_title": "新しいバージョン、公開中\uD83C\uDF89"
}

View File

@@ -0,0 +1,106 @@
{
"album_info_card_backup_album_excluded": "WYKLUCZONE",
"album_info_card_backup_album_included": "WŁĄCZONE",
"album_viewer_appbar_share_delete": "Usuń album",
"album_viewer_appbar_share_err_delete": "Nie udało się usunąć albumu",
"album_viewer_appbar_share_err_leave": "Nie udało się wyjść z albumu",
"album_viewer_appbar_share_err_remove": "Wystąpiły problemy z usunięciem plików z albumu",
"album_viewer_appbar_share_err_title": "Nie udało się zmienić tytułu albumu",
"album_viewer_appbar_share_leave": "Opuść album",
"album_viewer_appbar_share_remove": "Usuń z albumu",
"album_viewer_page_share_add_users": "Dodaj użytkowników",
"backup_album_selection_page_albums_device": "Albumy na urządzeniu ({})",
"backup_album_selection_page_albums_tap": "Stuknij, aby włączyć, stuknij dwukrotnie, aby wykluczyć",
"backup_album_selection_page_assets_scatter": "Pliki mogą być rozproszone w wielu albumach. Dzięki temu albumy mogą być włączane lub wyłączane podczas procesu tworzenia kopii zapasowej.",
"backup_album_selection_page_select_albums": "Zaznacz albumy",
"backup_album_selection_page_selection_info": "Info o wyborze",
"backup_album_selection_page_total_assets": "Łącznie unikalnych plików",
"backup_all": "Wszystkie",
"backup_controller_page_albums": "Backup Albumów",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Zaznaczone: ",
"backup_controller_page_backup_sub": "Tworzenie kopii zapasowych zdjęć i filmów",
"backup_controller_page_cancel": "Anuluj",
"backup_controller_page_created": "Utworzony na: {}",
"backup_controller_page_desc_backup": "Włącz backup, aby automatycznie przesyłać nowe zasoby na serwer.",
"backup_controller_page_excluded": "Wykluczone: ",
"backup_controller_page_failed": "Nieudane ({})",
"backup_controller_page_filename": "Nazwa pliku: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Informacje o kopii zapasowej",
"backup_controller_page_none_selected": "Brak wybranych",
"backup_controller_page_remainder": "Reszta",
"backup_controller_page_remainder_sub": "Pozostałe zdjęcia i albumy do wykonania kopii zapasowej z wyboru",
"backup_controller_page_select": "Zaznacz",
"backup_controller_page_server_storage": "Pamięć Serwera",
"backup_controller_page_start_backup": "Rozpocznij Backup",
"backup_controller_page_status_off": "Backup jest wyłączony",
"backup_controller_page_status_on": "Backup jest włączony",
"backup_controller_page_storage_format": "{} z {} wykorzystanych",
"backup_controller_page_to_backup": "Albumy do backupu",
"backup_controller_page_total": "Łącznie",
"backup_controller_page_total_sub": "Wszystkie unikalne zdjęcia i filmy z wybranych albumów",
"backup_controller_page_turn_off": "Wyłącz Backup",
"backup_controller_page_turn_on": "Włącz Backup",
"backup_controller_page_uploading_file_info": "Przesyłanie informacji o pliku",
"backup_err_only_album": "Nie można usunąć tylko i wyłącznie albumu",
"backup_info_card_assets": "pliki",
"control_bottom_app_bar_delete": "Usuń",
"create_shared_album_page_share": "Udostępnij",
"create_shared_album_page_share_add_assets": "DODAJ PLIKI",
"create_shared_album_page_share_select_photos": "Zaznacz Zdjęcia",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"date_format": "E, LLL d, y • h:mm a",
"delete_dialog_alert": "Te elementy zostaną trwale usunięte z Immich i z Twojego urządzenia",
"delete_dialog_cancel": "Anuluj",
"delete_dialog_ok": "Usuń",
"delete_dialog_title": "Usuń trwale",
"exif_bottom_sheet_description": "Dodaj opis...",
"exif_bottom_sheet_details": "SZCZEGÓŁY",
"exif_bottom_sheet_location": "LOKALIZACJA",
"login_form_button_text": "Login",
"login_form_email_hint": "twojmail@email.com",
"login_form_endpoint_hint": "http://ip-twojego-serwera:port/api",
"login_form_endpoint_url": "URL Serwera",
"login_form_err_http": "Proszę określić http:// lub https://",
"login_form_err_invalid_email": "Niepoprawny emaill",
"login_form_err_leading_whitespace": "Białe znaki",
"login_form_err_trailing_whitespace": "Białe znaki po przecinku",
"login_form_failed_login": "Błąd logowania, sprawdź adres url serwera, email i hasło.",
"login_form_label_email": "Email",
"login_form_label_password": "Hasło",
"login_form_password_hint": "hasło",
"login_form_save_login": "Pozostań zalogowany",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Klient i serwer są aktualne",
"profile_drawer_sign_out": "Wyloguj się",
"search_bar_hint": "Szukaj swoich zdjęć",
"search_page_no_objects": "Brak informacji o obiektach",
"search_page_no_places": "Brak informacji o miejscu",
"search_page_places": "Miejsca",
"search_page_things": "Rzeczy",
"search_result_page_new_search_hint": "Nowe wyszukiwanie",
"select_additional_user_for_sharing_page_suggestions": "Propozycje",
"select_user_for_sharing_page_err_album": "Nie udało się utworzyć albumu",
"select_user_for_sharing_page_share_suggestions": "Propozycje",
"share_add": "Dodaj",
"share_add_photos": "Dodaj zdjęcia",
"share_add_title": "Dodaj tytuł",
"share_create_album": "Utwórz album",
"share_invite": "Zaproś do albumu",
"sharing_page_album": "Udostępnione albumy",
"sharing_page_description": "Twórz wspóldzielone albumy, aby udostępniać zdjęcia i filmy osobom w sieci.",
"sharing_page_empty_list": "PUSTA LISTA",
"sharing_silver_appbar_create_shared_album": "Utwórz współdzielony album",
"sharing_silver_appbar_share_partner": "Udostępnij partnerce/partnerowi",
"tab_controller_nav_photos": "Zdjęcia",
"tab_controller_nav_search": "Szukaj",
"tab_controller_nav_sharing": "Udostępnianie",
"version_announcement_overlay_ack": "Potwierdzenie",
"version_announcement_overlay_release_notes": "informacje o wydaniu",
"version_announcement_overlay_text_1": "Cześć przyjacielu, jest nowe wydanie",
"version_announcement_overlay_text_2": "prosimy o poświęcenie czasu na odwiedzenie ",
"version_announcement_overlay_text_3": " i upewnij się, że twoja konfiguracja docker-compose i .env jest aktualna, aby zapobiec błędnym konfiguracjom, zwłaszcza jeśli używasz WatchTower lub dowolnego mechanizmu, który obsługuje automatyczną aktualizację aplikacji serwera.",
"version_announcement_overlay_title": "Nowa wersja serwera dostępna \uD83C\uDF89"
}

View File

@@ -19,6 +19,8 @@ PODS:
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- SAMKeychain (1.5.3) - SAMKeychain (1.5.3)
- shared_preferences_ios (0.0.1):
- Flutter
- sqflite (0.0.2): - sqflite (0.0.2):
- Flutter - Flutter
- FMDB (>= 2.7.5) - FMDB (>= 2.7.5)
@@ -38,6 +40,7 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
@@ -64,6 +67,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_ios/ios" :path: ".symlinks/plugins/path_provider_ios/ios"
photo_manager: photo_manager:
:path: ".symlinks/plugins/photo_manager/ios" :path: ".symlinks/plugins/photo_manager/ios"
shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/ios" :path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios: url_launcher_ios:
@@ -83,6 +88,7 @@ SPEC CHECKSUMS:
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de

View File

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

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.10.0</string> <string>1.18.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>14</string> <string>35</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true /> <true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
@@ -82,5 +82,18 @@
<array> <array>
<string>https</string> <string>https</string>
</array> </array>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>de</string>
<string>da</string>
<string>es</string>
<string>fr</string>
<string>it</string>
<string>fi</string>
<string>ja</string>
<string>pl</string>
</array>
</dict> </dict>
</plist> </plist>

View File

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

View File

@@ -5,12 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000946"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000227">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="16.3225"> <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.526426">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="7.096281">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.476898">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="102.893162">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="130.468341">
</testcase> </testcase>

View File

@@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
@@ -36,7 +37,28 @@ void main() async {
), ),
); );
runApp(const ProviderScope(child: ImmichApp())); await EasyLocalization.ensureInitialized();
var locales = const [
// Default locale
Locale('en', 'US'),
// Additional locales
Locale('da', 'DK'),
Locale('de', 'DE'),
Locale('es', 'ES'),
Locale('fr', 'FR'),
Locale('it', 'IT'),
];
runApp(
EasyLocalization(
supportedLocales: locales,
path: 'assets/i18n',
useFallbackTranslations: true,
fallbackLocale: locales.first,
child: const ProviderScope(child: ImmichApp()),
),
);
} }
class ImmichApp extends ConsumerStatefulWidget { class ImmichApp extends ConsumerStatefulWidget {
@@ -96,6 +118,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
@override @override
initState() { initState() {
super.initState(); super.initState();
initApp().then((_) => debugPrint("App Init Completed")); initApp().then((_) => debugPrint("App Init Completed"));
} }
@@ -105,13 +128,15 @@ class ImmichAppState extends ConsumerState<ImmichApp>
super.dispose(); super.dispose();
} }
final _immichRouter = AppRouter();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var router = ref.watch(appRouterProvider);
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo(); ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
return MaterialApp( return MaterialApp(
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
home: Stack( home: Stack(
children: [ children: [
@@ -124,7 +149,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
primarySwatch: Colors.indigo, primarySwatch: Colors.indigo,
fontFamily: 'WorkSans', fontFamily: 'WorkSans',
snackBarTheme: const SnackBarThemeData( snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans')), contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
),
scaffoldBackgroundColor: immichBackgroundColor, scaffoldBackgroundColor: immichBackgroundColor,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: immichBackgroundColor, backgroundColor: immichBackgroundColor,
@@ -134,9 +160,10 @@ class ImmichAppState extends ConsumerState<ImmichApp>
systemOverlayStyle: SystemUiOverlayStyle.dark, systemOverlayStyle: SystemUiOverlayStyle.dark,
), ),
), ),
routeInformationParser: _immichRouter.defaultRouteParser(), routeInformationParser: router.defaultRouteParser(),
routerDelegate: _immichRouter.delegate( routerDelegate: router.delegate(
navigatorObservers: () => [TabNavigationObserver(ref: ref)]), navigatorObservers: () => [TabNavigationObserver(ref: ref)],
),
), ),
const ImmichLoadingOverlay(), const ImmichLoadingOverlay(),
const VersionAnnouncementOverlay(), const VersionAnnouncementOverlay(),

View File

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

View File

@@ -3,15 +3,20 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart'; import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:openapi/api.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> { class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService = ImageViewerService(); final ImageViewerService _imageViewerService;
ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle)); ImageViewerStateNotifier(this._imageViewerService)
: super(
ImageViewerPageState(
downloadAssetStatus: DownloadAssetStatus.idle,
),
);
void downloadAsset(ImmichAsset asset, BuildContext context) async { void downloadAsset(AssetResponseDto asset, BuildContext context) async {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset); bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
@@ -40,4 +45,6 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
} }
final imageViewerStateProvider = final imageViewerStateProvider =
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(((ref) => ImageViewerStateNotifier())); StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerStateNotifier(ref.watch(imageViewerServiceProvider))),
);

View File

@@ -1,31 +1,35 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:http/http.dart' as http;
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
final imageViewerServiceProvider =
Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider)));
class ImageViewerService { class ImageViewerService {
Future<bool> downloadAssetToDevice(ImmichAsset asset) async { final ApiService _apiService;
ImageViewerService(this._apiService);
Future<bool> downloadAssetToDevice(AssetResponseDto asset) async {
try { try {
String fileName = p.basename(asset.originalPath); String fileName = p.basename(asset.originalPath);
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
Uri filePath =
Uri.parse("$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
var res = await http.get( var res = await _apiService.assetApi.downloadFileWithHttpInfo(
filePath, asset.deviceAssetId,
headers: {"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"}, asset.deviceId,
isThumb: false,
isWeb: false,
); );
final AssetEntity? entity; final AssetEntity? entity;
if (asset.type == 'IMAGE') { if (asset.type == AssetTypeEnum.IMAGE) {
entity = await PhotoManager.editor.saveImage( entity = await PhotoManager.editor.saveImage(
res.bodyBytes, res.bodyBytes,
title: p.basename(asset.originalPath), title: p.basename(asset.originalPath),
@@ -37,14 +41,10 @@ class ImageViewerService {
entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName); entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
} }
if (entity != null) { return entity != null;
return true;
}
} catch (e) { } catch (e) {
debugPrint("Error saving file $e"); debugPrint("Error saving file $e");
return false; return false;
} }
return false;
} }
} }

View File

@@ -1,13 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart'; import 'package:openapi/api.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
class ExifBottomSheet extends ConsumerWidget { class ExifBottomSheet extends ConsumerWidget {
final ImmichAssetWithExif assetDetail; final AssetResponseDto assetDetail;
const ExifBottomSheet({Key? key, required this.assetDetail}) const ExifBottomSheet({Key? key, required this.assetDetail})
: super(key: key); : super(key: key);
@@ -15,171 +15,171 @@ class ExifBottomSheet extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
_buildMap() { _buildMap() {
return (assetDetail.exifInfo!.latitude != null && return Padding(
assetDetail.exifInfo!.longitude != null) padding: const EdgeInsets.symmetric(vertical: 16.0),
? Padding( child: Container(
padding: const EdgeInsets.symmetric(vertical: 16.0), height: 150,
child: Container( width: MediaQuery.of(context).size.width,
height: 150, decoration: const BoxDecoration(
width: MediaQuery.of(context).size.width, borderRadius: BorderRadius.all(Radius.circular(15)),
decoration: const BoxDecoration( ),
borderRadius: BorderRadius.all(Radius.circular(15)), child: FlutterMap(
), options: MapOptions(
child: FlutterMap( center: LatLng(
options: MapOptions( assetDetail.exifInfo?.latitude?.toDouble() ?? 0,
center: LatLng(assetDetail.exifInfo!.latitude!, assetDetail.exifInfo?.longitude?.toDouble() ?? 0,
assetDetail.exifInfo!.longitude!),
zoom: 16.0,
),
layers: [
TileLayerOptions(
urlTemplate:
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: ['a', 'b', 'c'],
attributionBuilder: (_) {
return const Text(
"© OpenStreetMap",
style: TextStyle(fontSize: 10),
);
},
),
MarkerLayerOptions(
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(assetDetail.exifInfo!.latitude!,
assetDetail.exifInfo!.longitude!),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png')),
),
],
),
],
),
), ),
) zoom: 16.0,
: Container(); ),
layers: [
TileLayerOptions(
urlTemplate:
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: ['a', 'b', 'c'],
attributionBuilder: (_) {
return const Text(
"© OpenStreetMap",
style: TextStyle(fontSize: 10),
);
},
),
MarkerLayerOptions(
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
assetDetail.exifInfo?.latitude?.toDouble() ?? 0,
assetDetail.exifInfo?.longitude?.toDouble() ?? 0,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
],
),
],
),
),
);
} }
_buildLocationText() { _buildLocationText() {
return (assetDetail.exifInfo!.city != null && return Text(
assetDetail.exifInfo!.state != null) "${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
? Text( style: TextStyle(
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}", fontSize: 12,
style: TextStyle( color: Colors.grey[200],
fontSize: 12, fontWeight: FontWeight.bold,
color: Colors.grey[200], ),
fontWeight: FontWeight.bold), );
)
: Container();
} }
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
child: ListView( child: ListView(
children: [ children: [
assetDetail.exifInfo?.dateTimeOriginal != null if (assetDetail.exifInfo?.dateTimeOriginal != null)
? Text( Text(
DateFormat('E, LLL d, y • h:mm a').format( DateFormat('date_format'.tr()).format(
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!), assetDetail.exifInfo!.dateTimeOriginal!,
), ),
style: TextStyle( style: TextStyle(
color: Colors.grey[400], color: Colors.grey[400],
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
), ),
) ),
: Container(),
Padding( Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Text( child: Text(
"Add Description...", "exif_bottom_sheet_description",
style: TextStyle( style: TextStyle(
color: Colors.grey[500], color: Colors.grey[500],
fontSize: 11, fontSize: 11,
), ),
), ).tr(),
), ),
// Location // Location
assetDetail.exifInfo?.latitude != null if (assetDetail.exifInfo?.latitude != null)
? Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Divider( Divider(
thickness: 1, thickness: 1,
color: Colors.grey[600], color: Colors.grey[600],
),
Text(
"LOCATION",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
_buildMap(),
_buildLocationText(),
Text(
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
)
],
), ),
) Text(
: Container(), "exif_bottom_sheet_location",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
).tr(),
if (assetDetail.exifInfo?.latitude != null &&
assetDetail.exifInfo?.longitude != null)
_buildMap(),
if (assetDetail.exifInfo?.city != null &&
assetDetail.exifInfo?.state != null)
_buildLocationText(),
Text(
"${assetDetail.exifInfo?.latitude?.toStringAsFixed(4)}, ${assetDetail.exifInfo?.longitude?.toStringAsFixed(4)}",
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
)
],
),
),
// Detail // Detail
assetDetail.exifInfo != null if (assetDetail.exifInfo != null)
? Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Divider( Divider(
thickness: 1, thickness: 1,
color: Colors.grey[600], color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"DETAILS",
style:
TextStyle(fontSize: 11, color: Colors.grey[400]),
),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.image),
title: Text(
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: assetDetail.exifInfo?.exifImageHeight != null
? Text(
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ")
: Container(),
),
assetDetail.exifInfo?.make != null
? ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.camera),
title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
style: const TextStyle(
fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
)
: Container()
],
), ),
) Padding(
: Container() padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"exif_bottom_sheet_details",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
).tr(),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.image),
title: Text(
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: assetDetail.exifInfo?.exifImageHeight != null
? Text(
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ",
)
: null,
),
if (assetDetail.exifInfo?.make != null)
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.camera),
title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / (assetDetail.exifInfo?.exposureTime ?? 1)).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} ",
),
),
],
),
),
], ],
), ),
); );

View File

@@ -17,16 +17,20 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
bool allowMoving = _status == _RemoteImageStatus.full; bool allowMoving = _status == _RemoteImageStatus.full;
return PhotoView( return PhotoView(
imageProvider: _imageProvider, imageProvider: _imageProvider,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
maxScale: allowMoving ? 1.0 : PhotoViewComputedScale.contained, maxScale: allowMoving ? 1.0 : PhotoViewComputedScale.contained,
enablePanAlways: true, enablePanAlways: true,
scaleStateChangedCallback: _scaleStateChanged, scaleStateChangedCallback: _scaleStateChanged,
onScaleEnd: _onScaleListener); onScaleEnd: _onScaleListener,
);
} }
void _onScaleListener(BuildContext context, ScaleEndDetails details, void _onScaleListener(
PhotoViewControllerValue controllerValue) { BuildContext context,
ScaleEndDetails details,
PhotoViewControllerValue controllerValue,
) {
// Disable swipe events when zoomed in // Disable swipe events when zoomed in
if (_zoomedIn) return; if (_zoomedIn) return;
@@ -42,12 +46,17 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
} }
CachedNetworkImageProvider _authorizedImageProvider(String url) { CachedNetworkImageProvider _authorizedImageProvider(String url) {
return CachedNetworkImageProvider(url, return CachedNetworkImageProvider(
headers: {"Authorization": widget.authToken}, cacheKey: url); url,
headers: {"Authorization": widget.authToken},
cacheKey: url,
);
} }
void _performStateTransition( void _performStateTransition(
_RemoteImageStatus newStatus, CachedNetworkImageProvider provider) { _RemoteImageStatus newStatus,
CachedNetworkImageProvider provider,
) {
// Transition to same status is forbidden // Transition to same status is forbidden
if (_status == newStatus) return; if (_status == newStatus) return;
// Transition full -> thumbnail is forbidden // Transition full -> thumbnail is forbidden
@@ -67,19 +76,22 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
_authorizedImageProvider(widget.thumbnailUrl); _authorizedImageProvider(widget.thumbnailUrl);
_imageProvider = thumbnailProvider; _imageProvider = thumbnailProvider;
thumbnailProvider thumbnailProvider.resolve(const ImageConfiguration()).addListener(
.resolve(const ImageConfiguration()) ImageStreamListener((ImageInfo imageInfo, _) {
.addListener(ImageStreamListener((ImageInfo imageInfo, _) { _performStateTransition(
_performStateTransition(_RemoteImageStatus.thumbnail, thumbnailProvider); _RemoteImageStatus.thumbnail,
})); thumbnailProvider,
);
}),
);
CachedNetworkImageProvider fullProvider = CachedNetworkImageProvider fullProvider =
_authorizedImageProvider(widget.imageUrl); _authorizedImageProvider(widget.imageUrl);
fullProvider fullProvider.resolve(const ImageConfiguration()).addListener(
.resolve(const ImageConfiguration()) ImageStreamListener((ImageInfo imageInfo, _) {
.addListener(ImageStreamListener((ImageInfo imageInfo, _) { _performStateTransition(_RemoteImageStatus.full, fullProvider);
_performStateTransition(_RemoteImageStatus.full, fullProvider); }),
})); );
} }
@override @override
@@ -90,14 +102,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
} }
class RemotePhotoView extends StatefulWidget { class RemotePhotoView extends StatefulWidget {
const RemotePhotoView( const RemotePhotoView({
{Key? key, Key? key,
required this.thumbnailUrl, required this.thumbnailUrl,
required this.imageUrl, required this.imageUrl,
required this.authToken, required this.authToken,
required this.onSwipeDown, required this.onSwipeDown,
required this.onSwipeUp}) required this.onSwipeUp,
: super(key: key); }) : super(key: key);
final String thumbnailUrl; final String thumbnailUrl;
final String imageUrl; final String imageUrl;

View File

@@ -3,17 +3,17 @@ import 'dart:developer';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
const TopControlAppBar( const TopControlAppBar({
{Key? key, Key? key,
required this.asset, required this.asset,
required this.onMoreInfoPressed, required this.onMoreInfoPressed,
required this.onDownloadPressed}) required this.onDownloadPressed,
: super(key: key); }) : super(key: key);
final ImmichAsset asset; final AssetResponseDto asset;
final Function onMoreInfoPressed; final Function onMoreInfoPressed;
final Function onDownloadPressed; final Function onDownloadPressed;
@@ -54,12 +54,13 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
: const Icon(Icons.favorite_border_rounded), : const Icon(Icons.favorite_border_rounded),
), ),
IconButton( IconButton(
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
onPressed: () { onPressed: () {
onMoreInfoPressed(); onMoreInfoPressed();
}, },
icon: const Icon(Icons.more_horiz_rounded)) icon: const Icon(Icons.more_horiz_rounded),
)
], ],
); );
} }

View File

@@ -11,29 +11,34 @@ import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget { class ImageViewerPage extends HookConsumerWidget {
final String imageUrl; final String imageUrl;
final String heroTag; final String heroTag;
final String thumbnailUrl; final String thumbnailUrl;
final ImmichAsset asset; final AssetResponseDto asset;
final AssetService _assetService = AssetService();
ImmichAssetWithExif? assetDetail;
ImageViewerPage( AssetResponseDto? assetDetail;
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
: super(key: key); ImageViewerPage({
Key? key,
required this.imageUrl,
required this.heroTag,
required this.thumbnailUrl,
required this.asset,
}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus; final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
getAssetExif() async { getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id); assetDetail =
await ref.watch(assetServiceProvider).getAssetById(asset.id);
} }
showInfo() { showInfo() {
@@ -48,10 +53,13 @@ class ImageViewerPage extends HookConsumerWidget {
); );
} }
useEffect(() { useEffect(
getAssetExif(); () {
return null; getAssetExif();
}, []); return null;
},
[],
);
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -59,31 +67,33 @@ class ImageViewerPage extends HookConsumerWidget {
asset: asset, asset: asset,
onMoreInfoPressed: showInfo, onMoreInfoPressed: showInfo,
onDownloadPressed: () { onDownloadPressed: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context); ref
.watch(imageViewerStateProvider.notifier)
.downloadAsset(asset, context);
}, },
), ),
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
children: [ children: [
Center( Center(
child: Hero( child: Hero(
tag: heroTag, tag: heroTag,
child: RemotePhotoView( child: RemotePhotoView(
thumbnailUrl: thumbnailUrl, thumbnailUrl: thumbnailUrl,
imageUrl: imageUrl, imageUrl: imageUrl,
authToken: "Bearer ${box.get(accessTokenKey)}", authToken: "Bearer ${box.get(accessTokenKey)}",
onSwipeDown: () => AutoRouter.of(context).pop(), onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(), onSwipeUp: () => showInfo(),
)
), ),
), ),
if (downloadAssetStatus == DownloadAssetStatus.loading) ),
const Center( if (downloadAssetStatus == DownloadAssetStatus.loading)
child: DownloadLoadingIndicator(), const Center(
), child: DownloadLoadingIndicator(),
], ),
), ],
), ),
),
); );
} }
} }

View File

@@ -12,22 +12,22 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget { class VideoViewerPage extends HookConsumerWidget {
final String videoUrl; final String videoUrl;
final ImmichAsset asset; final AssetResponseDto asset;
ImmichAssetWithExif? assetDetail; AssetResponseDto? assetDetail;
final AssetService _assetService = AssetService();
VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) : super(key: key); VideoViewerPage({Key? key, required this.videoUrl, required this.asset})
: super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus; final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey); String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
@@ -44,13 +44,17 @@ class VideoViewerPage extends HookConsumerWidget {
} }
getAssetExif() async { getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id); assetDetail =
await ref.watch(assetServiceProvider).getAssetById(asset.id);
} }
useEffect(() { useEffect(
getAssetExif(); () {
return null; getAssetExif();
}, []); return null;
},
[],
);
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -60,7 +64,9 @@ class VideoViewerPage extends HookConsumerWidget {
showInfo(); showInfo();
}, },
onDownloadPressed: () { onDownloadPressed: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context); ref
.watch(imageViewerStateProvider.notifier)
.downloadAsset(asset, context);
}, },
), ),
body: SwipeDetector( body: SwipeDetector(
@@ -93,7 +99,8 @@ class VideoThumbnailPlayer extends StatefulWidget {
final String url; final String url;
final String? jwtToken; final String? jwtToken;
const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) : super(key: key); const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken})
: super(key: key);
@override @override
State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState(); State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
@@ -111,8 +118,10 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
Future<void> initializePlayer() async { Future<void> initializePlayer() async {
try { try {
videoPlayerController = videoPlayerController = VideoPlayerController.network(
VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}); widget.url,
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
);
await videoPlayerController.initialize(); await videoPlayerController.initialize();
_createChewieController(); _createChewieController();
@@ -142,7 +151,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return chewieController != null && chewieController!.videoPlayerController.value.isInitialized return chewieController?.videoPlayerController.value.isInitialized == true
? SizedBox( ? SizedBox(
child: Chewie( child: Chewie(
controller: chewieController!, controller: chewieController!,

View File

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

View File

@@ -1,19 +1,20 @@
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:equatable/equatable.dart'; import 'package:collection/collection.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
enum BackUpProgressEnum { idle, inProgress, done } enum BackUpProgressEnum { idle, inProgress, done }
class BackUpState extends Equatable { class BackUpState {
// enum // enum
final BackUpProgressEnum backupProgress; final BackUpProgressEnum backupProgress;
final List<String> allAssetOnDatabase; final List<String> allAssetsInDatabase;
final double progressInPercentage; final double progressInPercentage;
final CancellationToken cancelToken; final CancellationToken cancelToken;
final ServerInfo serverInfo; final ServerInfoResponseDto serverInfo;
/// All available albums on the device /// All available albums on the device
final List<AvailableAlbum> availableAlbums; final List<AvailableAlbum> availableAlbums;
@@ -26,9 +27,12 @@ class BackUpState extends Equatable {
/// All assets from the selected albums that have been backup /// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds; final Set<String> selectedAlbumsBackupAssetsIds;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
const BackUpState({ const BackUpState({
required this.backupProgress, required this.backupProgress,
required this.allAssetOnDatabase, required this.allAssetsInDatabase,
required this.progressInPercentage, required this.progressInPercentage,
required this.cancelToken, required this.cancelToken,
required this.serverInfo, required this.serverInfo,
@@ -37,23 +41,25 @@ class BackUpState extends Equatable {
required this.excludedBackupAlbums, required this.excludedBackupAlbums,
required this.allUniqueAssets, required this.allUniqueAssets,
required this.selectedAlbumsBackupAssetsIds, required this.selectedAlbumsBackupAssetsIds,
required this.currentUploadAsset,
}); });
BackUpState copyWith({ BackUpState copyWith({
BackUpProgressEnum? backupProgress, BackUpProgressEnum? backupProgress,
List<String>? allAssetOnDatabase, List<String>? allAssetsInDatabase,
double? progressInPercentage, double? progressInPercentage,
CancellationToken? cancelToken, CancellationToken? cancelToken,
ServerInfo? serverInfo, ServerInfoResponseDto? serverInfo,
List<AvailableAlbum>? availableAlbums, List<AvailableAlbum>? availableAlbums,
Set<AssetPathEntity>? selectedBackupAlbums, Set<AssetPathEntity>? selectedBackupAlbums,
Set<AssetPathEntity>? excludedBackupAlbums, Set<AssetPathEntity>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets, Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds, Set<String>? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset,
}) { }) {
return BackUpState( return BackUpState(
backupProgress: backupProgress ?? this.backupProgress, backupProgress: backupProgress ?? this.backupProgress,
allAssetOnDatabase: allAssetOnDatabase ?? this.allAssetOnDatabase, allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase,
progressInPercentage: progressInPercentage ?? this.progressInPercentage, progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken, cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo, serverInfo: serverInfo ?? this.serverInfo,
@@ -61,28 +67,51 @@ class BackUpState extends Equatable {
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums, selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums, excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets, allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds, selectedAlbumsBackupAssetsIds:
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
); );
} }
@override @override
String toString() { String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetOnDatabase: $allAssetOnDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)'; return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
} }
@override @override
List<Object> get props { bool operator ==(Object other) {
return [ if (identical(this, other)) return true;
backupProgress, final collectionEquals = const DeepCollectionEquality().equals;
allAssetOnDatabase,
progressInPercentage, return other is BackUpState &&
cancelToken, other.backupProgress == backupProgress &&
serverInfo, collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
availableAlbums, other.progressInPercentage == progressInPercentage &&
selectedBackupAlbums, other.cancelToken == cancelToken &&
excludedBackupAlbums, other.serverInfo == serverInfo &&
allUniqueAssets, collectionEquals(other.availableAlbums, availableAlbums) &&
selectedAlbumsBackupAssetsIds, collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
]; collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
collectionEquals(
other.selectedAlbumsBackupAssetsIds,
selectedAlbumsBackupAssetsIds,
) &&
other.currentUploadAsset == currentUploadAsset;
}
@override
int get hashCode {
return backupProgress.hashCode ^
allAssetsInDatabase.hashCode ^
progressInPercentage.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^
allUniqueAssets.hashCode ^
selectedAlbumsBackupAssetsIds.hashCode ^
currentUploadAsset.hashCode;
} }
} }

View File

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

View File

@@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
import 'package:photo_manager/photo_manager.dart';
class ErrorUploadAsset extends Equatable {
final String id;
final DateTime createdAt;
final String fileName;
final String fileType;
final AssetEntity asset;
final String errorMessage;
const ErrorUploadAsset({
required this.id,
required this.createdAt,
required this.fileName,
required this.fileType,
required this.asset,
required this.errorMessage,
});
ErrorUploadAsset copyWith({
String? id,
DateTime? createdAt,
String? fileName,
String? fileType,
AssetEntity? asset,
String? errorMessage,
}) {
return ErrorUploadAsset(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
fileName: fileName ?? this.fileName,
fileType: fileType ?? this.fileType,
asset: asset ?? this.asset,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
String toString() {
return 'ErrorUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType, asset: $asset, errorMessage: $errorMessage)';
}
@override
List<Object> get props {
return [
id,
fileName,
fileType,
errorMessage,
];
}
}

View File

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

View File

@@ -5,27 +5,35 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; 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_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> { class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier({this.ref}) BackupNotifier(
: super( this._backupService,
this._serverInfoService,
this._authState,
this.ref,
) : super(
BackUpState( BackUpState(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
allAssetOnDatabase: const [], allAssetsInDatabase: const [],
progressInPercentage: 0, progressInPercentage: 0,
cancelToken: CancellationToken(), cancelToken: CancellationToken(),
serverInfo: ServerInfo( serverInfo: ServerInfoResponseDto(
diskAvailable: "0", diskAvailable: "0",
diskAvailableRaw: 0, diskAvailableRaw: 0,
diskSize: "0", diskSize: "0",
diskSizeRaw: 0, diskSizeRaw: 0,
diskUsagePercentage: 0.0, diskUsagePercentage: 0,
diskUse: "0", diskUse: "0",
diskUseRaw: 0, diskUseRaw: 0,
), ),
@@ -34,12 +42,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
excludedBackupAlbums: const {}, excludedBackupAlbums: const {},
allUniqueAssets: const {}, allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {}, selectedAlbumsBackupAssetsIds: const {},
currentUploadAsset: CurrentUploadAsset(
id: '...',
createdAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
), ),
); ) {
getBackupInfo();
}
Ref? ref; final BackupService _backupService;
final BackupService _backupService = BackupService(); final ServerInfoService _serverInfoService;
final ServerInfoService _serverInfoService = ServerInfoService(); final AuthenticationState _authState;
final Ref ref;
/// ///
/// UI INTERACTION /// UI INTERACTION
@@ -92,11 +109,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// If this is the first time performing backup - set the default selected album to be /// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (Recent on Android, Recents on iOS) /// the one that has all assets (Recent on Android, Recents on iOS)
/// ///
Future<void> getBackupAlbumsInfo() async { Future<void> _getBackupAlbumsInfo() async {
// Get all albums on the device // Get all albums on the device
List<AvailableAlbum> availableAlbums = []; List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList( List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true, type: RequestType.common); hasAll: true,
type: RequestType.common,
);
for (AssetPathEntity album in albums) { for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
@@ -139,7 +158,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Get album that contains all assets // Get album that contains all assets
var list = await PhotoManager.getAssetPathList( var list = await PhotoManager.getAssetPathList(
hasAll: true, onlyAll: true, type: RequestType.common); hasAll: true,
onlyAll: true,
type: RequestType.common,
);
AssetPathEntity albumHasAllAssets = list.first; AssetPathEntity albumHasAllAssets = list.first;
backupAlbumInfoBox.put( backupAlbumInfoBox.put(
@@ -158,13 +180,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) { for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId); var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
state = state.copyWith( state = state.copyWith(
selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset}); selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset},
);
} }
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) { for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId); var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
state = state.copyWith( state = state.copyWith(
excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset}); excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset},
);
} }
} catch (e) { } catch (e) {
debugPrint("[ERROR] Failed to generate album from id $e"); debugPrint("[ERROR] Failed to generate album from id $e");
@@ -176,7 +200,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Find the assets that are not overlapping between the two sets /// Find the assets that are not overlapping between the two sets
/// Those assets are unique and are used as the total assets /// Those assets are unique and are used as the total assets
/// ///
void _updateBackupAssetCount() async { Future<void> _updateBackupAssetCount() async {
Set<AssetEntity> assetsFromSelectedAlbums = {}; Set<AssetEntity> assetsFromSelectedAlbums = {};
Set<AssetEntity> assetsFromExcludedAlbums = {}; Set<AssetEntity> assetsFromExcludedAlbums = {};
@@ -194,27 +218,30 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<AssetEntity> allUniqueAssets = Set<AssetEntity> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
List<String> allAssetOnDatabase = var allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return;
}
// Find asset that were backup from selected albums // Find asset that were backup from selected albums
Set<String> selectedAlbumsBackupAssets = Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id)); Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId)); .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
if (allUniqueAssets.isEmpty) { if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device"); debugPrint("No Asset On Device");
state = state.copyWith( state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
allAssetOnDatabase: allAssetOnDatabase, allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: {}, allUniqueAssets: {},
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
); );
return; return;
} else { } else {
state = state.copyWith( state = state.copyWith(
allAssetOnDatabase: allAssetOnDatabase, allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: allUniqueAssets, allUniqueAssets: allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
); );
@@ -222,6 +249,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Save to persistent storage // Save to persistent storage
_updatePersistentAlbumsSelection(); _updatePersistentAlbumsSelection();
return;
} }
/// ///
@@ -229,10 +258,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// which albums are selected or excluded /// which albums are selected or excluded
/// and then update the UI according to those information /// and then update the UI according to those information
/// ///
void getBackupInfo() async { Future<void> getBackupInfo() async {
await getBackupAlbumsInfo(); await Future.wait([
_updateServerInfo(); _getBackupAlbumsInfo(),
_updateBackupAssetCount(); _updateServerInfo(),
]);
await _updateBackupAssetCount();
} }
/// ///
@@ -255,11 +287,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Invoke backup process /// Invoke backup process
/// ///
void startBackupProcess() async { void startBackupProcess() async {
_updateServerInfo();
_updateBackupAssetCount();
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
await getBackupInfo();
var authResult = await PhotoManager.requestPermissionExtend(); var authResult = await PhotoManager.requestPermissionExtend();
if (authResult.isAuth) { if (authResult.isAuth) {
await PhotoManager.clearFileCache(); await PhotoManager.clearFileCache();
@@ -270,10 +301,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return; return;
} }
Set<AssetEntity> assetsWillBeBackup = state.allUniqueAssets; Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up // Remove item that has already been backed up
for (var assetId in state.allAssetOnDatabase) { for (var assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId); assetsWillBeBackup.removeWhere((e) => e.id == assetId);
} }
@@ -283,33 +314,51 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Perform Backup // Perform Backup
state = state.copyWith(cancelToken: CancellationToken()); state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _backupService.backupAsset(
_onAssetUploaded, _onUploadProgress); assetsWillBeBackup,
state.cancelToken,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,
_onBackupError,
);
} else { } else {
PhotoManager.openSetting(); PhotoManager.openSetting();
} }
} }
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
state = state.copyWith(currentUploadAsset: currentUploadAsset);
}
void cancelBackup() { void cancelBackup() {
state.cancelToken.cancel(); state.cancelToken.cancel();
state = state.copyWith( state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0); backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0,
);
} }
void _onAssetUploaded(String deviceAssetId, String deviceId) { void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state.copyWith(selectedAlbumsBackupAssetsIds: { state = state.copyWith(
...state.selectedAlbumsBackupAssetsIds, selectedAlbumsBackupAssetsIds: {
deviceAssetId ...state.selectedAlbumsBackupAssetsIds,
}, allAssetOnDatabase: [ deviceAssetId
...state.allAssetOnDatabase, },
deviceAssetId allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
]); );
if (state.allUniqueAssets.length - if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length == state.selectedAlbumsBackupAssetsIds.length ==
0) { 0) {
state = state.copyWith( state = state.copyWith(
backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0); backupProgress: BackUpProgressEnum.done,
progressInPercentage: 0.0,
);
} }
_updateServerInfo(); _updateServerInfo();
@@ -317,59 +366,55 @@ class BackupNotifier extends StateNotifier<BackUpState> {
void _onUploadProgress(int sent, int total) { void _onUploadProgress(int sent, int total) {
state = state.copyWith( state = state.copyWith(
progressInPercentage: (sent.toDouble() / total.toDouble() * 100)); progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
}
void _updateServerInfo() async {
var serverInfo = await _serverInfoService.getServerInfo();
// Update server info
state = state.copyWith(
serverInfo: ServerInfo(
diskSize: serverInfo.diskSize,
diskUse: serverInfo.diskUse,
diskAvailable: serverInfo.diskAvailable,
diskSizeRaw: serverInfo.diskSizeRaw,
diskUseRaw: serverInfo.diskUseRaw,
diskAvailableRaw: serverInfo.diskAvailableRaw,
diskUsagePercentage: serverInfo.diskUsagePercentage,
),
); );
} }
void resumeBackup() { Future<void> _updateServerInfo() async {
var authState = ref?.read(authenticationProvider); var serverInfo = await _serverInfoService.getServerInfo();
// Update server info
if (serverInfo != null) {
state = state.copyWith(
serverInfo: serverInfo,
);
}
}
void resumeBackup() {
// Check if user is login // Check if user is login
var accessKey = Hive.box(userInfoBox).get(accessTokenKey); var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
// User has been logged out return // User has been logged out return
if (authState != null) { if (accessKey == null || !_authState.isAuthenticated) {
if (accessKey == null || !authState.isAuthenticated) { debugPrint("[resumeBackup] not authenticated - abort");
debugPrint("[resumeBackup] not authenticated - abort"); return;
}
// Check if this device is enable backup by the user
if ((_authState.deviceInfo.deviceId == _authState.deviceId) &&
_authState.deviceInfo.isAutoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
return; return;
} }
// Check if this device is enable backup by the user // Run backup
if ((authState.deviceInfo.deviceId == authState.deviceId) && debugPrint("[resumeBackup] Start back up");
authState.deviceInfo.isAutoBackup) { startBackupProcess();
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
startBackupProcess();
}
return;
} }
return;
} }
} }
final backupProvider = final backupProvider =
StateNotifierProvider<BackupNotifier, BackUpState>((ref) { StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(ref: ref); return BackupNotifier(
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider),
ref,
);
}); });

View File

@@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
ErrorBackupListNotifier() : super({});
add(ErrorUploadAsset errorAsset) {
state = state.union({errorAsset});
}
remove(ErrorUploadAsset errorAsset) {
state = state.difference({errorAsset});
}
empty() {
state = {};
}
}
final errorBackupListProvider =
StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
(ref) => ErrorBackupListNotifier(),
);

View File

@@ -2,38 +2,53 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart'; import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/files_helper.dart'; import 'package:immich_mobile/utils/files_helper.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http; import 'package:cancellation_token_http/http.dart' as http;
class BackupService { final backupServiceProvider = Provider(
final NetworkService _networkService = NetworkService(); (ref) => BackupService(
ref.watch(apiServiceProvider),
),
);
Future<List<String>> getDeviceBackupAsset() async { class BackupService {
final ApiService _apiService;
BackupService(this._apiService);
Future<List<String>?> getDeviceBackupAsset() async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
Response response = await _networkService.getRequest(url: "asset/$deviceId"); try {
List<dynamic> result = jsonDecode(response.toString()); return await _apiService.assetApi.getUserAssetsByDeviceId(deviceId);
} catch (e) {
return result.cast<String>(); debugPrint('Error [getDeviceBackupAsset] ${e.toString()}');
return null;
}
} }
backupAsset(Set<AssetEntity> assetList, http.CancellationToken cancelToken, backupAsset(
Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgress) async { Set<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb,
) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file; File? file;
http.MultipartFile? thumbnailUploadData;
for (var entity in assetList) { for (var entity in assetList) {
try { try {
if (entity.type == AssetType.video) { if (entity.type == AssetType.video) {
@@ -44,7 +59,8 @@ class BackupService {
if (file != null) { if (file != null) {
String originalFileName = await entity.titleAsync; String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0]; String fileNameWithoutPath =
originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path); var fileExtension = p.extension(file.path);
var mimeType = FileHelper.getMimeType(file.path); var mimeType = FileHelper.getMimeType(file.path);
var fileStream = file.openRead(); var fileStream = file.openRead();
@@ -59,24 +75,14 @@ class BackupService {
), ),
); );
// Build thumbnail multipart data
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
if (thumbnailData != null) {
thumbnailUploadData = http.MultipartFile.fromBytes(
"thumbnailData",
List.from(thumbnailData),
filename: fileNameWithoutPath,
contentType: MediaType(
"image",
"jpeg",
),
);
}
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var req = MultipartRequest('POST', Uri.parse('$savedEndpoint/asset/upload'), var req = MultipartRequest(
onProgress: ((bytes, totalBytes) => uploadProgress(bytes, totalBytes))); 'POST',
Uri.parse('$savedEndpoint/asset/upload'),
onProgress: ((bytes, totalBytes) =>
uploadProgressCb(bytes, totalBytes)),
);
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}"; req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
req.fields['deviceAssetId'] = entity.id; req.fields['deviceAssetId'] = entity.id;
@@ -88,15 +94,40 @@ class BackupService {
req.fields['fileExtension'] = fileExtension; req.fields['fileExtension'] = fileExtension;
req.fields['duration'] = entity.videoDuration.toString(); req.fields['duration'] = entity.videoDuration.toString();
if (thumbnailUploadData != null) {
req.files.add(thumbnailUploadData);
}
req.files.add(assetRawUploadData); req.files.add(assetRawUploadData);
var res = await req.send(cancellationToken: cancelToken); setCurrentUploadAssetCb(
CurrentUploadAsset(
id: entity.id,
createdAt: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
),
);
if (res.statusCode == 201) { var response = await req.send(cancellationToken: cancelToken);
if (response.statusCode == 201) {
singleAssetDoneCb(entity.id, deviceId); singleAssetDoneCb(entity.id, deviceId);
} else {
var data = await response.stream.bytesToString();
var error = jsonDecode(data);
debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
);
errorCb(
ErrorUploadAsset(
asset: entity,
id: entity.id,
createdAt: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
errorMessage: error['error'],
),
);
continue;
} }
} }
} on http.CancelledException { } on http.CancelledException {
@@ -126,14 +157,29 @@ class BackupService {
} }
} }
Future<DeviceInfoRemote> setAutoBackup(bool status, String deviceId, String deviceType) async { Future<DeviceInfoResponseDto> setAutoBackup(
var res = await _networkService.patchRequest(url: 'device-info', data: { bool status,
"isAutoBackup": status, String deviceId,
"deviceId": deviceId, DeviceTypeEnum deviceType,
"deviceType": deviceType, ) async {
}); try {
var updatedDeviceInfo = await _apiService.deviceInfoApi.updateDeviceInfo(
UpdateDeviceInfoDto(
deviceId: deviceId,
deviceType: deviceType,
isAutoBackup: status,
),
);
return DeviceInfoRemote.fromJson(res.toString()); if (updatedDeviceInfo == null) {
throw Exception("Error updating device info");
}
return updatedDeviceInfo;
} catch (e) {
debugPrint("Error setAutoBackup: ${e.toString()}");
throw Error();
}
} }
} }

View File

@@ -1,6 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
@@ -14,16 +15,24 @@ class AlbumInfoCard extends HookConsumerWidget {
final Uint8List? imageData; final Uint8List? imageData;
final AssetPathEntity albumInfo; final AssetPathEntity albumInfo;
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo}) : super(key: key); const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo})
: super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo); final bool isSelected =
final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo); ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
ColorFilter selectedFilter = ColorFilter.mode(Theme.of(context).primaryColor.withAlpha(100), BlendMode.darken); ColorFilter selectedFilter = ColorFilter.mode(
ColorFilter excludedFilter = ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken); Theme.of(context).primaryColor.withAlpha(100),
ColorFilter unselectedFilter = const ColorFilter.mode(Colors.black, BlendMode.color); BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
_buildSelectedTextBox() { _buildSelectedTextBox() {
if (isSelected) { if (isSelected) {
@@ -31,9 +40,13 @@ class AlbumInfoCard extends HookConsumerWidget {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text( label: const Text(
"INCLUDED", "album_info_card_backup_album_included",
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold), style: TextStyle(
), fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
); );
} else if (isExcluded) { } else if (isExcluded) {
@@ -41,14 +54,18 @@ class AlbumInfoCard extends HookConsumerWidget {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text( label: const Text(
"EXCLUDED", "album_info_card_backup_album_excluded",
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold), style: TextStyle(
), fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: Colors.red[300], backgroundColor: Colors.red[300],
); );
} }
return Container(); return const SizedBox();
} }
_buildImageFilter() { _buildImageFilter() {
@@ -69,7 +86,7 @@ class AlbumInfoCard extends HookConsumerWidget {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) { if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "Cannot remove the only album", msg: "backup_err_only_album".tr(),
toastType: ToastType.error, toastType: ToastType.error,
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
); );
@@ -85,20 +102,27 @@ class AlbumInfoCard extends HookConsumerWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
if (isExcluded) { if (isExcluded) {
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(albumInfo); ref
.watch(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else { } else {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 && if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 &&
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo)) { ref
.watch(backupProvider)
.selectedBackupAlbums
.contains(albumInfo)) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "Cannot exclude the only album", msg: "backup_err_only_album".tr(),
toastType: ToastType.error, toastType: ToastType.error,
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
); );
return; return;
} }
ref.watch(backupProvider.notifier).addExcludedAlbumForBackup(albumInfo); ref
.watch(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
} }
}, },
child: Card( child: Card(
@@ -121,18 +145,27 @@ class AlbumInfoCard extends HookConsumerWidget {
width: 200, width: 200,
height: 200, height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
image: DecorationImage( image: DecorationImage(
colorFilter: _buildImageFilter(), colorFilter: _buildImageFilter(),
image: imageData != null image: imageData != null
? MemoryImage(imageData!) ? MemoryImage(imageData!)
: const AssetImage('assets/immich-logo-no-outline.png') as ImageProvider, : const AssetImage(
'assets/immich-logo-no-outline.png',
) as ImageProvider,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
), ),
child: null, child: null,
), ),
Positioned(bottom: 10, left: 25, child: _buildSelectedTextBox()) Positioned(
bottom: 10,
left: 25,
child: _buildSelectedTextBox(),
)
], ],
), ),
Padding( Padding(
@@ -150,13 +183,22 @@ class AlbumInfoCard extends HookConsumerWidget {
Text( Text(
albumInfo.name, albumInfo.name,
style: TextStyle( style: TextStyle(
fontSize: 14, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), fontSize: 14,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 2.0), padding: const EdgeInsets.only(top: 2.0),
child: Text( child: Text(
albumInfo.assetCount.toString() + (albumInfo.isAll ? " (ALL)" : ""), albumInfo.assetCount.toString() +
style: TextStyle(fontSize: 12, color: Colors.grey[600]), (albumInfo.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
), ),
) )
], ],
@@ -165,7 +207,8 @@ class AlbumInfoCard extends HookConsumerWidget {
), ),
IconButton( IconButton(
onPressed: () { onPressed: () {
AutoRouter.of(context).push(AlbumPreviewRoute(album: albumInfo)); AutoRouter.of(context)
.push(AlbumPreviewRoute(album: albumInfo));
}, },
icon: Icon( icon: Icon(
Icons.image_outlined, Icons.image_outlined,

View File

@@ -1,10 +1,16 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class BackupInfoCard extends StatelessWidget { class BackupInfoCard extends StatelessWidget {
final String title; final String title;
final String subtitle; final String subtitle;
final String info; final String info;
const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key); const BackupInfoCard({
Key? key,
required this.title,
required this.subtitle,
required this.info,
}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -39,7 +45,7 @@ class BackupInfoCard extends StatelessWidget {
info, info,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
), ),
const Text("assets"), const Text("backup_info_card_assets").tr(),
], ],
), ),
), ),

View File

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

View File

@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
@@ -16,10 +17,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
useEffect(() { useEffect(
ref.read(backupProvider.notifier).getBackupAlbumsInfo(); () {
return null; ref.read(backupProvider.notifier).getBackupInfo();
}, []); return null;
},
[],
);
_buildAlbumSelectionList() { _buildAlbumSelectionList() {
if (availableAlbums.isEmpty) { if (availableAlbums.isEmpty) {
@@ -37,8 +41,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
itemBuilder: ((context, index) { itemBuilder: ((context, index) {
var thumbnailData = availableAlbums[index].thumbnailData; var thumbnailData = availableAlbums[index].thumbnailData;
return Padding( return Padding(
padding: index == 0 ? const EdgeInsets.only(left: 16.00) : const EdgeInsets.all(0), padding: index == 0
child: AlbumInfoCard(imageData: thumbnailData, albumInfo: availableAlbums[index].albumEntity), ? const EdgeInsets.only(left: 16.00)
: const EdgeInsets.all(0),
child: AlbumInfoCard(
imageData: thumbnailData,
albumInfo: availableAlbums[index].albumEntity,
),
); );
}), }),
), ),
@@ -51,7 +60,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) { if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "Cannot remove the only album", msg: "backup_err_only_album".tr(),
toastType: ToastType.error, toastType: ToastType.error,
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
); );
@@ -67,10 +76,16 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
onTap: removeSelection, onTap: removeSelection,
child: Chip( child: Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
label: Text( label: Text(
album.name, album.name,
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
), ),
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
deleteIconColor: Colors.white, deleteIconColor: Colors.white,
@@ -88,7 +103,9 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
_buildExcludedAlbumNameChip() { _buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) { return excludedBackupAlbums.map((album) {
void removeSelection() { void removeSelection() {
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album); ref
.watch(backupProvider.notifier)
.removeExcludedAlbumForBackup(album);
} }
return GestureDetector( return GestureDetector(
@@ -97,10 +114,16 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
padding: const EdgeInsets.only(right: 8.0), padding: const EdgeInsets.only(right: 8.0),
child: Chip( child: Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
label: Text( label: Text(
album.name, album.name,
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
), ),
backgroundColor: Colors.red[300], backgroundColor: Colors.red[300],
deleteIconColor: Colors.white, deleteIconColor: Colors.white,
@@ -122,27 +145,31 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
icon: const Icon(Icons.arrow_back_ios_rounded), icon: const Icon(Icons.arrow_back_ios_rounded),
), ),
title: const Text( title: const Text(
"Select Albums", "backup_album_selection_page_select_albums",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ).tr(),
elevation: 0, elevation: 0,
), ),
body: ListView( body: ListView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), padding:
child: Text( const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
"Selection Info", child: const Text(
"backup_album_selection_page_selection_info",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ).tr(),
), ),
// Selected Album Chips // Selected Album Chips
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap( child: Wrap(
children: [..._buildSelectedAlbumNameChip(), ..._buildExcludedAlbumNameChip()], children: [
..._buildSelectedAlbumNameChip(),
..._buildExcludedAlbumNameChip()
],
), ),
), ),
@@ -164,11 +191,19 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
ListTile( ListTile(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
title: Text( title: Text(
"Total unique assets", "backup_album_selection_page_total_assets",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.grey[700]), style: TextStyle(
), fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.grey[700],
),
).tr(),
trailing: Text( trailing: Text(
ref.watch(backupProvider).allUniqueAssets.length.toString(), ref
.watch(backupProvider)
.allUniqueAssets
.length
.toString(),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
), ),
@@ -179,19 +214,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
ListTile( ListTile(
title: Text( title: Text(
"Albums on device (${availableAlbums.length.toString()})", "backup_album_selection_page_albums_device"
.tr(args: [availableAlbums.length.toString()]),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ),
subtitle: Padding( subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text( child: Text(
"Tap to include, double tap to exclude", "backup_album_selection_page_albums_tap",
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ).tr(),
), ),
trailing: IconButton( trailing: IconButton(
splashRadius: 16, splashRadius: 16,
@@ -206,23 +242,28 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 5, elevation: 5,
title: Text( title: Text(
'Selection Info', 'backup_album_selection_page_selection_info',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
), ),
), ).tr(),
content: SingleChildScrollView( content: SingleChildScrollView(
child: ListBody( child: ListBody(
children: [ children: [
Text( Text(
'Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.', 'backup_album_selection_page_assets_scatter',
style: TextStyle(fontSize: 14, color: Colors.grey[700]), style: TextStyle(
), fontSize: 14,
color: Colors.grey[700],
),
).tr(),
], ],
), ),
), ),

View File

@@ -1,7 +1,9 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -24,16 +26,19 @@ class BackupControllerPage extends HookConsumerWidget {
? false ? false
: true; : true;
useEffect(() { useEffect(
if (backupState.backupProgress != BackUpProgressEnum.inProgress) { () {
ref.read(backupProvider.notifier).getBackupInfo(); if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
} ref.watch(backupProvider.notifier).getBackupInfo();
}
ref ref
.watch(websocketProvider.notifier) .watch(websocketProvider.notifier)
.stopListenToEvent('on_upload_success'); .stopListenToEvent('on_upload_success');
return null; return null;
}, []); },
[],
);
Widget _buildStorageInformation() { Widget _buildStorageInformation() {
return ListTile( return ListTile(
@@ -42,9 +47,9 @@ class BackupControllerPage extends HookConsumerWidget {
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
), ),
title: const Text( title: const Text(
"Server Storage", "backup_controller_page_server_storage",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ).tr(),
subtitle: Padding( subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Column( child: Column(
@@ -56,7 +61,7 @@ class BackupControllerPage extends HookConsumerWidget {
padding: padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0), const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2), barRadius: const Radius.circular(2),
lineHeight: 6.0, lineHeight: 10.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0, percent: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey, backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor, progressColor: Theme.of(context).primaryColor,
@@ -64,8 +69,12 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 12.0), padding: const EdgeInsets.only(top: 12.0),
child: Text( child: const Text('backup_controller_page_storage_format').tr(
'${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'), args: [
backupState.serverInfo.diskUse,
backupState.serverInfo.diskSize
],
),
), ),
], ],
), ),
@@ -74,11 +83,13 @@ class BackupControllerPage extends HookConsumerWidget {
} }
ListTile _buildBackupController() { ListTile _buildBackupController() {
var backUpOption = var backUpOption = authenticationState.deviceInfo.isAutoBackup
authenticationState.deviceInfo.isAutoBackup ? "on" : "off"; ? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
var isAutoBackup = authenticationState.deviceInfo.isAutoBackup; var isAutoBackup = authenticationState.deviceInfo.isAutoBackup;
var backupBtnText = var backupBtnText = authenticationState.deviceInfo.isAutoBackup
authenticationState.deviceInfo.isAutoBackup ? "off" : "on"; ? "backup_controller_page_turn_off".tr()
: "backup_controller_page_turn_on".tr();
return ListTile( return ListTile(
isThreeLine: true, isThreeLine: true,
leading: isAutoBackup leading: isAutoBackup
@@ -88,7 +99,7 @@ class BackupControllerPage extends HookConsumerWidget {
) )
: const Icon(Icons.cloud_off_rounded), : const Icon(Icons.cloud_off_rounded),
title: Text( title: Text(
"Back up is $backUpOption", backUpOption,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ),
subtitle: Padding( subtitle: Padding(
@@ -96,12 +107,11 @@ class BackupControllerPage extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
!isAutoBackup if (!isAutoBackup)
? const Text( const Text(
"Turn on backup to automatically upload new assets to the server.", "backup_controller_page_desc_backup",
style: TextStyle(fontSize: 14), style: TextStyle(fontSize: 14),
) ).tr(),
: Container(),
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton( child: OutlinedButton(
@@ -112,16 +122,20 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
), ),
onPressed: () { onPressed: () {
isAutoBackup if (isAutoBackup) {
? ref ref
.watch(authenticationProvider.notifier) .read(authenticationProvider.notifier)
.setAutoBackup(false) .setAutoBackup(false);
: ref } else {
.watch(authenticationProvider.notifier) ref
.setAutoBackup(true); .read(authenticationProvider.notifier)
.setAutoBackup(true);
}
}, },
child: Text("Turn $backupBtnText Backup", child: Text(
style: const TextStyle(fontWeight: FontWeight.bold)), backupBtnText,
style: const TextStyle(fontWeight: FontWeight.bold),
),
), ),
) )
], ],
@@ -131,13 +145,13 @@ class BackupControllerPage extends HookConsumerWidget {
} }
Widget _buildSelectedAlbumName() { Widget _buildSelectedAlbumName() {
var text = "Selected: "; var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums; var albums = ref.watch(backupProvider).selectedBackupAlbums;
if (albums.isNotEmpty) { if (albums.isNotEmpty) {
for (var album in albums) { for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") { if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (All), "; text += "${album.name} (${'backup_all'.tr()}), ";
} else { } else {
text += "${album.name}, "; text += "${album.name}, ";
} }
@@ -148,27 +162,29 @@ class BackupControllerPage extends HookConsumerWidget {
child: Text( child: Text(
text.trim().substring(0, text.length - 2), text.trim().substring(0, text.length - 2),
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold,
),
), ),
); );
} else { } else {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Text( child: Text(
"None selected", "backup_controller_page_none_selected".tr(),
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold,
),
), ),
); );
} }
} }
Widget _buildExcludedAlbumName() { Widget _buildExcludedAlbumName() {
var text = "Excluded: "; var text = "backup_controller_page_excluded".tr();
var albums = ref.watch(backupProvider).excludedBackupAlbums; var albums = ref.watch(backupProvider).excludedBackupAlbums;
if (albums.isNotEmpty) { if (albums.isNotEmpty) {
@@ -181,13 +197,14 @@ class BackupControllerPage extends HookConsumerWidget {
child: Text( child: Text(
text.trim().substring(0, text.length - 2), text.trim().substring(0, text.length - 2),
style: TextStyle( style: TextStyle(
color: Colors.red[300], color: Colors.red[300],
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold,
),
), ),
); );
} else { } else {
return Container(); return const SizedBox();
} }
} }
@@ -204,17 +221,19 @@ class BackupControllerPage extends HookConsumerWidget {
borderOnForeground: false, borderOnForeground: false,
child: ListTile( child: ListTile(
minVerticalPadding: 15, minVerticalPadding: 15,
title: const Text("Backup Albums", title: const Text(
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), "backup_controller_page_albums",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
).tr(),
subtitle: Padding( subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
"Albums to be backup", "backup_controller_page_to_backup",
style: TextStyle(color: Color(0xFF808080), fontSize: 12), style: TextStyle(color: Color(0xFF808080), fontSize: 12),
), ).tr(),
_buildSelectedAlbumName(), _buildSelectedAlbumName(),
_buildExcludedAlbumName() _buildExcludedAlbumName()
], ],
@@ -231,65 +250,215 @@ class BackupControllerPage extends HookConsumerWidget {
onPressed: () { onPressed: () {
AutoRouter.of(context).push(const BackupAlbumSelectionRoute()); AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
}, },
child: const Padding( child: Padding(
padding: EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 16.0, vertical: 16.0,
), ),
child: Text( child: const Text(
"Select", "backup_controller_page_select",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ).tr(),
), ),
), ),
), ),
); );
} }
_buildCurrentBackupAssetInfoCard() {
return ListTile(
leading: Icon(
Icons.info_outline_rounded,
color: Theme.of(context).primaryColor,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"backup_controller_page_uploading_file_info",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
if (ref.watch(errorBackupListProvider).isNotEmpty)
ActionChip(
avatar: Icon(
Icons.info,
size: 24,
color: Colors.red[400],
),
elevation: 1,
visualDensity: VisualDensity.compact,
label: Text(
"backup_controller_page_failed",
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
fontSize: 11,
),
).tr(
args: [ref.watch(errorBackupListProvider).length.toString()],
),
backgroundColor: Colors.white,
onPressed: () {
AutoRouter.of(context).push(const FailedBackupStatusRoute());
},
),
],
),
subtitle: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearPercentIndicator(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 10.0,
trailing: Text(
" ${backupState.progressInPercentage.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
percent: backupState.progressInPercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Table(
border: TableBorder.all(
color: Colors.black12,
width: 1,
),
children: [
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: const Text(
'backup_controller_page_filename',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [
backupState.currentUploadAsset.fileName,
backupState.currentUploadAsset.fileType
.toLowerCase()
],
),
),
),
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[200],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: const Text(
"backup_controller_page_created",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [
DateFormat.yMMMMd('en_US').format(
DateTime.parse(
backupState.currentUploadAsset.createdAt
.toString(),
),
)
],
),
),
),
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: const Text(
"backup_controller_page_id",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(args: [backupState.currentUploadAsset.id]),
),
),
],
),
],
),
),
],
),
);
}
void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty();
ref.watch(backupProvider.notifier).startBackupProcess();
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
title: const Text( title: const Text(
"Backup", "backup_controller_page_backup",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ).tr(),
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent(); ref.watch(websocketProvider.notifier).listenUploadEvent();
AutoRouter.of(context).pop(true); AutoRouter.of(context).pop(true);
}, },
splashRadius: 24, splashRadius: 24,
icon: const Icon( icon: const Icon(
Icons.arrow_back_ios_rounded, Icons.arrow_back_ios_rounded,
)), ),
),
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
child: ListView( child: ListView(
// crossAxisAlignment: CrossAxisAlignment.start, // crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: const Text(
"Backup Information", "backup_controller_page_info",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ).tr(),
), ),
_buildFolderSelectionTile(), _buildFolderSelectionTile(),
BackupInfoCard( BackupInfoCard(
title: "Total", title: "backup_controller_page_total".tr(),
subtitle: "All unique photos and videos from selected albums", subtitle: "backup_controller_page_total_sub".tr(),
info: "${backupState.allUniqueAssets.length}", info: "${backupState.allUniqueAssets.length}",
), ),
BackupInfoCard( BackupInfoCard(
title: "Backup", title: "backup_controller_page_backup".tr(),
subtitle: subtitle: "backup_controller_page_backup_sub".tr(),
"Photos and videos from selected albums that are backup",
info: "${backupState.selectedAlbumsBackupAssetsIds.length}", info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
), ),
BackupInfoCard( BackupInfoCard(
title: "Remainder", title: "backup_controller_page_remainder".tr(),
subtitle: subtitle: "backup_controller_page_remainder_sub".tr(),
"Photos and videos that has not been backing up from selected albums",
info: info:
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}", "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
), ),
@@ -298,23 +467,11 @@ class BackupControllerPage extends HookConsumerWidget {
const Divider(), const Divider(),
_buildStorageInformation(), _buildStorageInformation(),
const Divider(), const Divider(),
_buildCurrentBackupAssetInfoCard(),
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.only(
child: Text( top: 24,
"Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"), ),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(children: [
const Text("Backup Progress:"),
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
backupState.backupProgress == BackUpProgressEnum.inProgress
? const CircularProgressIndicator.adaptive()
: const Text("Done"),
]),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Container( child: Container(
child: child:
backupState.backupProgress == BackUpProgressEnum.inProgress backupState.backupProgress == BackUpProgressEnum.inProgress
@@ -322,25 +479,33 @@ class BackupControllerPage extends HookConsumerWidget {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
primary: Colors.red[300], primary: Colors.red[300],
onPrimary: Colors.grey[50], onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
), ),
onPressed: () { onPressed: () {
ref.read(backupProvider.notifier).cancelBackup(); ref.read(backupProvider.notifier).cancelBackup();
}, },
child: const Text("Cancel"), child: const Text(
"backup_controller_page_cancel",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(),
) )
: ElevatedButton( : ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
primary: Theme.of(context).primaryColor, primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50], onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
), ),
onPressed: shouldBackup onPressed: shouldBackup ? startBackup : null,
? () { child: const Text(
ref "backup_controller_page_start_backup",
.read(backupProvider.notifier) style: TextStyle(
.startBackupProcess(); fontSize: 14,
} fontWeight: FontWeight.bold,
: null, ),
child: const Text("Start Backup"), ).tr(),
), ),
), ),
) )

View File

@@ -0,0 +1,141 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
class FailedBackupStatusPage extends HookConsumerWidget {
const FailedBackupStatusPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorBackupList = ref.watch(errorBackupListProvider);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text(
"Failed Backup (${errorBackupList.length})",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
),
),
),
body: ListView.builder(
shrinkWrap: true,
itemCount: errorBackupList.length,
itemBuilder: ((context, index) {
var errorAsset = errorBackupList.elementAt(index);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 4,
),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 100,
minHeight: 150,
maxWidth: 100,
maxHeight: 200,
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(15),
topLeft: Radius.circular(15),
),
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: AssetEntityImageProvider(
errorAsset.asset,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(512),
thumbnailFormat: ThumbnailFormat.jpeg,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
DateFormat.yMMMMd('en_US').format(
DateTime.parse(
errorAsset.createdAt.toString(),
),
),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
Icon(
Icons.error,
color: Colors.red.withAlpha(200),
size: 18,
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
errorAsset.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Theme.of(context).primaryColor,
),
),
),
Text(
errorAsset.errorMessage,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
),
),
],
),
),
)
],
),
),
);
}),
),
);
}
}

View File

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

View File

@@ -1,11 +1,9 @@
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
class ImmichAssetGroupByDate { class ImmichAssetGroupByDate {
final String date; final String date;
List<ImmichAsset> assets; List<AssetResponseDto> assets;
ImmichAssetGroupByDate({ ImmichAssetGroupByDate({
required this.date, required this.date,
required this.assets, required this.assets,
@@ -13,7 +11,7 @@ class ImmichAssetGroupByDate {
ImmichAssetGroupByDate copyWith({ ImmichAssetGroupByDate copyWith({
String? date, String? date,
List<ImmichAsset>? assets, List<AssetResponseDto>? assets,
}) { }) {
return ImmichAssetGroupByDate( return ImmichAssetGroupByDate(
date: date ?? this.date, date: date ?? this.date,
@@ -21,24 +19,6 @@ class ImmichAssetGroupByDate {
); );
} }
Map<String, dynamic> toMap() {
return {
'date': date,
'assets': assets.map((x) => x.toMap()).toList(),
};
}
factory ImmichAssetGroupByDate.fromMap(Map<String, dynamic> map) {
return ImmichAssetGroupByDate(
date: map['date'] ?? '',
assets: List<ImmichAsset>.from(map['assets']?.map((x) => ImmichAsset.fromMap(x))),
);
}
String toJson() => json.encode(toMap());
factory ImmichAssetGroupByDate.fromJson(String source) => ImmichAssetGroupByDate.fromMap(json.decode(source));
@override @override
String toString() => 'ImmichAssetGroupByDate(date: $date, assets: $assets)'; String toString() => 'ImmichAssetGroupByDate(date: $date, assets: $assets)';
@@ -46,7 +26,9 @@ class ImmichAssetGroupByDate {
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is ImmichAssetGroupByDate && other.date == date && listEquals(other.assets, assets); return other is ImmichAssetGroupByDate &&
other.date == date &&
listEquals(other.assets, assets);
} }
@override @override
@@ -75,28 +57,9 @@ class GetAllAssetResponse {
); );
} }
Map<String, dynamic> toMap() {
return {
'count': count,
'data': data.map((x) => x.toMap()).toList(),
'nextPageKey': nextPageKey,
};
}
factory GetAllAssetResponse.fromMap(Map<String, dynamic> map) {
return GetAllAssetResponse(
count: map['count']?.toInt() ?? 0,
data: List<ImmichAssetGroupByDate>.from(map['data']?.map((x) => ImmichAssetGroupByDate.fromMap(x))),
nextPageKey: map['nextPageKey'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory GetAllAssetResponse.fromJson(String source) => GetAllAssetResponse.fromMap(json.decode(source));
@override @override
String toString() => 'GetAllAssetResponse(count: $count, data: $data, nextPageKey: $nextPageKey)'; String toString() =>
'GetAllAssetResponse(count: $count, data: $data, nextPageKey: $nextPageKey)';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View File

@@ -1,12 +1,10 @@
import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
class HomePageState { class HomePageState {
final bool isMultiSelectEnable; final bool isMultiSelectEnable;
final Set<ImmichAsset> selectedItems; final Set<AssetResponseDto> selectedItems;
final Set<String> selectedDateGroup; final Set<String> selectedDateGroup;
HomePageState({ HomePageState({
required this.isMultiSelectEnable, required this.isMultiSelectEnable,
@@ -16,7 +14,7 @@ class HomePageState {
HomePageState copyWith({ HomePageState copyWith({
bool? isMultiSelectEnable, bool? isMultiSelectEnable,
Set<ImmichAsset>? selectedItems, Set<AssetResponseDto>? selectedItems,
Set<String>? selectedDateGroup, Set<String>? selectedDateGroup,
}) { }) {
return HomePageState( return HomePageState(
@@ -26,26 +24,6 @@ class HomePageState {
); );
} }
Map<String, dynamic> toMap() {
return {
'isMultiSelectEnable': isMultiSelectEnable,
'selectedItems': selectedItems.map((x) => x.toMap()).toList(),
'selectedDateGroup': selectedDateGroup.toList(),
};
}
factory HomePageState.fromMap(Map<String, dynamic> map) {
return HomePageState(
isMultiSelectEnable: map['isMultiSelectEnable'] ?? false,
selectedItems: Set<ImmichAsset>.from(map['selectedItems']?.map((x) => ImmichAsset.fromMap(x))),
selectedDateGroup: Set<String>.from(map['selectedDateGroup']),
);
}
String toJson() => json.encode(toMap());
factory HomePageState.fromJson(String source) => HomePageState.fromMap(json.decode(source));
@override @override
String toString() => String toString() =>
'HomePageState(isMultiSelectEnable: $isMultiSelectEnable, selectedItems: $selectedItems, selectedDateGroup: $selectedDateGroup)'; 'HomePageState(isMultiSelectEnable: $isMultiSelectEnable, selectedItems: $selectedItems, selectedDateGroup: $selectedDateGroup)';
@@ -62,5 +40,8 @@ class HomePageState {
} }
@override @override
int get hashCode => isMultiSelectEnable.hashCode ^ selectedItems.hashCode ^ selectedDateGroup.hashCode; int get hashCode =>
isMultiSelectEnable.hashCode ^
selectedItems.hashCode ^
selectedDateGroup.hashCode;
} }

View File

@@ -1,6 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart'; import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
class HomePageStateNotifier extends StateNotifier<HomePageState> { class HomePageStateNotifier extends StateNotifier<HomePageState> {
HomePageStateNotifier() HomePageStateNotifier()
@@ -13,7 +13,9 @@ class HomePageStateNotifier extends StateNotifier<HomePageState> {
); );
void addSelectedDateGroup(String dateGroupTitle) { void addSelectedDateGroup(String dateGroupTitle) {
state = state.copyWith(selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle}); state = state.copyWith(
selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle},
);
} }
void removeSelectedDateGroup(String dateGroupTitle) { void removeSelectedDateGroup(String dateGroupTitle) {
@@ -24,34 +26,39 @@ class HomePageStateNotifier extends StateNotifier<HomePageState> {
state = state.copyWith(selectedDateGroup: currentDateGroup); state = state.copyWith(selectedDateGroup: currentDateGroup);
} }
void enableMultiSelect(Set<ImmichAsset> selectedItems) { void enableMultiSelect(Set<AssetResponseDto> selectedItems) {
state = state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems); state =
state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems);
} }
void disableMultiSelect() { void disableMultiSelect() {
state = state.copyWith(isMultiSelectEnable: false, selectedItems: {}, selectedDateGroup: {}); state = state.copyWith(
isMultiSelectEnable: false,
selectedItems: {},
selectedDateGroup: {},
);
} }
void addSingleSelectedItem(ImmichAsset asset) { void addSingleSelectedItem(AssetResponseDto asset) {
state = state.copyWith(selectedItems: {...state.selectedItems, asset}); state = state.copyWith(selectedItems: {...state.selectedItems, asset});
} }
void addMultipleSelectedItems(List<ImmichAsset> assets) { void addMultipleSelectedItems(List<AssetResponseDto> assets) {
state = state.copyWith(selectedItems: {...state.selectedItems, ...assets}); state = state.copyWith(selectedItems: {...state.selectedItems, ...assets});
} }
void removeSingleSelectedItem(ImmichAsset asset) { void removeSingleSelectedItem(AssetResponseDto asset) {
Set<ImmichAsset> currentList = state.selectedItems; Set<AssetResponseDto> currentList = state.selectedItems;
currentList.removeWhere((e) => e.id == asset.id); currentList.removeWhere((e) => e.id == asset.id);
state = state.copyWith(selectedItems: currentList); state = state.copyWith(selectedItems: currentList);
} }
void removeMultipleSelectedItem(List<ImmichAsset> assets) { void removeMultipleSelectedItem(List<AssetResponseDto> assets) {
Set<ImmichAsset> currentList = state.selectedItems; Set<AssetResponseDto> currentList = state.selectedItems;
for (ImmichAsset asset in assets) { for (AssetResponseDto asset in assets) {
currentList.removeWhere((e) => e.id == asset.id); currentList.removeWhere((e) => e.id == asset.id);
} }
@@ -60,4 +67,6 @@ class HomePageStateNotifier extends StateNotifier<HomePageState> {
} }
final homePageStateProvider = final homePageStateProvider =
StateNotifierProvider<HomePageStateNotifier, HomePageState>(((ref) => HomePageStateNotifier())); StateNotifierProvider<HomePageStateNotifier, HomePageState>(
((ref) => HomePageStateNotifier()),
);

View File

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

View File

@@ -1,110 +1,51 @@
import 'dart:convert'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_response.model.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; final assetServiceProvider = Provider(
(ref) => AssetService(
ref.watch(apiServiceProvider),
),
);
class AssetService { class AssetService {
final NetworkService _networkService = NetworkService(); final ApiService _apiService;
Future<List<ImmichAsset>?> getAllAsset() async { AssetService(this._apiService);
var res = await _networkService.getRequest(url: "asset/");
Future<List<AssetResponseDto>?> getAllAsset() async {
try { try {
List<dynamic> decodedData = jsonDecode(res.toString()); return await _apiService.assetApi.getAllAssets();
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
return result;
} catch (e) { } catch (e) {
debugPrint("Error getAllAsset ${e.toString()}"); debugPrint("Error [getAllAsset] ${e.toString()}");
}
return null;
}
Future<GetAllAssetResponse?> getAllAssetWithPagination() async {
var res = await _networkService.getRequest(url: "asset/all");
try {
Map<String, dynamic> decodedData = jsonDecode(res.toString());
GetAllAssetResponse result = GetAllAssetResponse.fromMap(decodedData);
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
}
return null;
}
Future<GetAllAssetResponse?> getOlderAsset(String? nextPageKey) async {
try {
var res = await _networkService.getRequest(
url: "asset/all?nextPageKey=$nextPageKey",
);
Map<String, dynamic> decodedData = jsonDecode(res.toString());
GetAllAssetResponse result = GetAllAssetResponse.fromMap(decodedData);
if (result.count != 0) {
return result;
}
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
}
return null;
}
Future<List<ImmichAsset>> getNewAsset(String latestDate) async {
try {
var res = await _networkService.getRequest(
url: "asset/new?latestDate=$latestDate",
);
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
if (result.isNotEmpty) {
return result;
}
return [];
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return [];
}
}
Future<ImmichAssetWithExif?> getAssetById(String assetId) async {
try {
var res = await _networkService.getRequest(
url: "asset/assetById/$assetId",
);
Map<String, dynamic> decodedData = jsonDecode(res.toString());
ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return null; return null;
} }
} }
Future<List<DeleteAssetResponse>?> deleteAssets(Set<ImmichAsset> deleteAssets) async { Future<AssetResponseDto?> getAssetById(String assetId) async {
try { try {
var payload = []; return await _apiService.assetApi.getAssetById(assetId);
} catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}");
return null;
}
}
Future<List<DeleteAssetResponseDto>?> deleteAssets(
Set<AssetResponseDto> deleteAssets,
) async {
try {
List<String> payload = [];
for (var asset in deleteAssets) { for (var asset in deleteAssets) {
payload.add(asset.id); payload.add(asset.id);
} }
var res = await _networkService.deleteRequest(url: "asset/", data: {"ids": payload}); return await _apiService.assetApi
.deleteAsset(DeleteAssetDto(ids: payload));
List<dynamic> decodedData = jsonDecode(res.toString());
List<DeleteAssetResponse> result = List.from(decodedData.map((a) => DeleteAssetResponse.fromMap(a)));
return result;
} catch (e) { } catch (e) {
debugPrint("Error getAllAsset ${e.toString()}"); debugPrint("Error getAllAsset ${e.toString()}");
return null; return null;

View File

@@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart'; import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
@@ -13,7 +14,10 @@ class ControlBottomAppBar extends StatelessWidget {
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.15, height: MediaQuery.of(context).size.height * 0.15,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)), borderRadius: const BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15),
),
color: Colors.grey[300]?.withOpacity(0.98), color: Colors.grey[300]?.withOpacity(0.98),
), ),
child: Column( child: Column(
@@ -25,7 +29,7 @@ class ControlBottomAppBar extends StatelessWidget {
children: [ children: [
ControlBoxButton( ControlBoxButton(
iconData: Icons.delete_forever_rounded, iconData: Icons.delete_forever_rounded,
label: "Delete", label: "control_bottom_app_bar_delete".tr(),
onPressed: () { onPressed: () {
showDialog( showDialog(
context: context, context: context,
@@ -46,8 +50,12 @@ class ControlBottomAppBar extends StatelessWidget {
} }
class ControlBoxButton extends StatelessWidget { class ControlBoxButton extends StatelessWidget {
const ControlBoxButton({Key? key, required this.label, required this.iconData, required this.onPressed}) const ControlBoxButton({
: super(key: key); Key? key,
required this.label,
required this.iconData,
required this.onPressed,
}) : super(key: key);
final String label; final String label;
final IconData iconData; final IconData iconData;

View File

@@ -1,8 +1,8 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart'; import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
import 'package:intl/intl.dart';
class DailyTitleText extends ConsumerWidget { class DailyTitleText extends ConsumerWidget {
const DailyTitleText({ const DailyTitleText({
@@ -12,15 +12,19 @@ class DailyTitleText extends ConsumerWidget {
}) : super(key: key); }) : super(key: key);
final String isoDate; final String isoDate;
final List<ImmichAsset> assetGroup; final List<AssetResponseDto> assetGroup;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year; var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year; var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy'; var formatDateTemplate = currentYear == groupYear
var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); ? "daily_title_text_date".tr()
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable; : "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 selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems; var selectedItems = ref.watch(homePageStateProvider).selectedItems;
@@ -35,23 +39,46 @@ class DailyTitleText extends ConsumerWidget {
selectedDateGroup.contains(dateText) && selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) { 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 // 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
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup); .watch(homePageStateProvider.notifier)
} else if (isMultiSelectEnable && selectedDateGroup.contains(dateText) && selectedDateGroup.length > 1) { .removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText); ref
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup); .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)) { } else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText); ref
ref.watch(homePageStateProvider.notifier).addMultipleSelectedItems(assetGroup); .watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.addMultipleSelectedItems(assetGroup);
} else { } else {
ref.watch(homePageStateProvider.notifier).enableMultiSelect(assetGroup.toSet()); ref
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText); .watch(homePageStateProvider.notifier)
.enableMultiSelect(assetGroup.toSet());
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
} }
} }
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0), padding: const EdgeInsets.only(
top: 29.0,
bottom: 29.0,
left: 12.0,
right: 12.0,
),
child: Row( child: Row(
children: [ children: [
Text( Text(

View File

@@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
@@ -13,29 +14,31 @@ class DeleteDialog extends ConsumerWidget {
return AlertDialog( return AlertDialog(
backgroundColor: Colors.grey[200], backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: const Text("Delete Permanently"), title: const Text("delete_dialog_title").tr(),
content: const Text("These items will be permanently deleted from Immich and from your device"), content: const Text("delete_dialog_alert").tr(),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text( child: const Text(
"Cancel", "delete_dialog_cancel",
style: TextStyle(color: Colors.blueGrey), style: TextStyle(color: Colors.blueGrey),
), ).tr(),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
ref.watch(assetProvider.notifier).deleteAssets(homePageState.selectedItems); ref
.watch(assetProvider.notifier)
.deleteAssets(homePageState.selectedItems);
ref.watch(homePageStateProvider.notifier).disableMultiSelect(); ref.watch(homePageStateProvider.notifier).disableMultiSelect();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Text( child: Text(
"Delete", "delete_dialog_ok",
style: TextStyle(color: Colors.red[400]), style: TextStyle(color: Colors.red[400]),
), ).tr(),
), ),
], ],
); );

View File

@@ -29,15 +29,18 @@ class DisableMultiSelectButton extends ConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton.icon( child: TextButton.icon(
onPressed: () { onPressed: () {
onPressed(); onPressed();
}, },
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
label: Text( label: Text(
selectedItemCount.toString(), '$selectedItemCount',
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 18), fontWeight: FontWeight.w600,
)), fontSize: 18,
),
),
),
), ),
), ),
), ),

View File

@@ -118,20 +118,24 @@ class DraggableScrollbar extends StatefulWidget {
this.labelConstraints, this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical), }) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbSemicircleBuilder( scrollThumbBuilder = _thumbSemicircleBuilder(
heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb), heightScrollThumb * 0.6,
scrollThumbKey,
alwaysVisibleScrollThumb,
),
super(key: key); super(key: key);
@override @override
DraggableScrollbarState createState() => DraggableScrollbarState(); DraggableScrollbarState createState() => DraggableScrollbarState();
static buildScrollThumbAndLabel( static buildScrollThumbAndLabel({
{required Widget scrollThumb, required Widget scrollThumb,
required Color backgroundColor, required Color backgroundColor,
required Animation<double>? thumbAnimation, required Animation<double>? thumbAnimation,
required Animation<double>? labelAnimation, required Animation<double>? labelAnimation,
required Text? labelText, required Text? labelText,
required BoxConstraints? labelConstraints, required BoxConstraints? labelConstraints,
required bool alwaysVisibleScrollThumb}) { required bool alwaysVisibleScrollThumb,
}) {
var scrollThumbAndLabel = labelText == null var scrollThumbAndLabel = labelText == null
? scrollThumb ? scrollThumb
: Row( : Row(
@@ -158,7 +162,10 @@ class DraggableScrollbar extends StatefulWidget {
} }
static ScrollThumbBuilder _thumbSemicircleBuilder( static ScrollThumbBuilder _thumbSemicircleBuilder(
double width, Key? scrollThumbKey, bool alwaysVisibleScrollThumb) { double width,
Key? scrollThumbKey,
bool alwaysVisibleScrollThumb,
) {
return ( return (
Color backgroundColor, Color backgroundColor,
Animation<double> thumbAnimation, Animation<double> thumbAnimation,
@@ -198,7 +205,9 @@ class DraggableScrollbar extends StatefulWidget {
} }
static ScrollThumbBuilder _thumbArrowBuilder( static ScrollThumbBuilder _thumbArrowBuilder(
Key? scrollThumbKey, bool alwaysVisibleScrollThumb) { Key? scrollThumbKey,
bool alwaysVisibleScrollThumb,
) {
return ( return (
Color backgroundColor, Color backgroundColor,
Animation<double> thumbAnimation, Animation<double> thumbAnimation,
@@ -234,7 +243,9 @@ class DraggableScrollbar extends StatefulWidget {
} }
static ScrollThumbBuilder _thumbRRectBuilder( static ScrollThumbBuilder _thumbRRectBuilder(
Key? scrollThumbKey, bool alwaysVisibleScrollThumb) { Key? scrollThumbKey,
bool alwaysVisibleScrollThumb,
) {
return ( return (
Color backgroundColor, Color backgroundColor,
Animation<double> thumbAnimation, Animation<double> thumbAnimation,
@@ -372,42 +383,44 @@ class DraggableScrollbarState extends State<DraggableScrollbar>
} }
return LayoutBuilder( return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {
//print("LayoutBuilder constraints=$constraints"); //print("LayoutBuilder constraints=$constraints");
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) { onNotification: (ScrollNotification notification) {
changePosition(notification); changePosition(notification);
return false; return false;
}, },
child: Stack( child: Stack(
children: <Widget>[ children: <Widget>[
RepaintBoundary( RepaintBoundary(
child: widget.child, child: widget.child,
), ),
RepaintBoundary( RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onVerticalDragStart: _onVerticalDragStart, onVerticalDragStart: _onVerticalDragStart,
onVerticalDragUpdate: _onVerticalDragUpdate, onVerticalDragUpdate: _onVerticalDragUpdate,
onVerticalDragEnd: _onVerticalDragEnd, onVerticalDragEnd: _onVerticalDragEnd,
child: Container( child: Container(
alignment: Alignment.topRight, alignment: Alignment.topRight,
margin: EdgeInsets.only(top: _barOffset), margin: EdgeInsets.only(top: _barOffset),
padding: widget.padding, padding: widget.padding,
child: widget.scrollThumbBuilder( child: widget.scrollThumbBuilder(
widget.backgroundColor, widget.backgroundColor,
_thumbAnimation, _thumbAnimation,
_labelAnimation, _labelAnimation,
widget.heightScrollThumb, widget.heightScrollThumb,
labelText: labelText, labelText: labelText,
labelConstraints: widget.labelConstraints, labelConstraints: widget.labelConstraints,
),
),
), ),
), ),
)), ],
], ),
), );
); },
}); );
} }
//scroll bar has received notification that it's view was scrolled //scroll bar has received notification that it's view was scrolled
@@ -498,7 +511,10 @@ class DraggableScrollbarState extends State<DraggableScrollbar>
} }
double viewDelta = getScrollViewDelta( double viewDelta = getScrollViewDelta(
details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent); details.delta.dy,
barMaxScrollExtent,
viewMaxScrollExtent,
);
_viewOffset = widget.controller.position.pixels + viewDelta; _viewOffset = widget.controller.position.pixels + viewDelta;
if (_viewOffset < widget.controller.position.minScrollExtent) { if (_viewOffset < widget.controller.position.minScrollExtent) {
@@ -579,7 +595,9 @@ class ArrowClipper extends CustomClipper<Path> {
path.lineTo(startPointX + arrowWidth, startPointY); path.lineTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth, startPointY + 1.0); path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
path.lineTo( path.lineTo(
startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0); startPointX + arrowWidth / 2,
startPointY - arrowWidth / 2 + 1.0,
);
path.lineTo(startPointX, startPointY + 1.0); path.lineTo(startPointX, startPointY + 1.0);
path.close(); path.close();
@@ -589,7 +607,9 @@ class ArrowClipper extends CustomClipper<Path> {
path.lineTo(startPointX, startPointY); path.lineTo(startPointX, startPointY);
path.lineTo(startPointX, startPointY - 1.0); path.lineTo(startPointX, startPointY - 1.0);
path.lineTo( path.lineTo(
startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0); startPointX + arrowWidth / 2,
startPointY + arrowWidth / 2 - 1.0,
);
path.lineTo(startPointX + arrowWidth, startPointY - 1.0); path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
path.close(); path.close();
@@ -615,7 +635,7 @@ class SlideFadeTransition extends StatelessWidget {
return AnimatedBuilder( return AnimatedBuilder(
animation: animation, animation: animation,
builder: (context, child) => builder: (context, child) =>
animation.value == 0.0 ? Container() : child!, animation.value == 0.0 ? const SizedBox() : child!,
child: SlideTransition( child: SlideTransition(
position: Tween( position: Tween(
begin: const Offset(0.3, 0.0), begin: const Offset(0.3, 0.0),

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
class ImageGrid extends ConsumerWidget { class ImageGrid extends ConsumerWidget {
final List<ImmichAsset> assetGroup; final List<AssetResponseDto> assetGroup;
const ImageGrid({Key? key, required this.assetGroup}) : super(key: key); const ImageGrid({Key? key, required this.assetGroup}) : super(key: key);
@@ -25,27 +25,26 @@ class ImageGrid extends ConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
ThumbnailImage(asset: assetGroup[index]), ThumbnailImage(asset: assetGroup[index]),
assetType == 'IMAGE' if (assetType != AssetTypeEnum.IMAGE)
? Container() Positioned(
: Positioned( top: 5,
top: 5, right: 5,
right: 5, child: Row(
child: Row( children: [
children: [ Text(
Text( assetGroup[index].duration.toString().substring(0, 7),
assetGroup[index].duration.toString().substring(0, 7), style: const TextStyle(
style: const TextStyle( color: Colors.white,
color: Colors.white, fontSize: 10,
fontSize: 10, ),
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
), ),
) const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
], ],
), ),
); );

View File

@@ -31,7 +31,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
pinned: false, pinned: false,
snap: false, snap: false,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5))), borderRadius: BorderRadius.all(Radius.circular(5)),
),
leading: Builder( leading: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
return Stack( return Stack(
@@ -49,30 +50,29 @@ class ImmichSliverAppBar extends ConsumerWidget {
}, },
), ),
), ),
serverInfoState.isVersionMismatch if (serverInfoState.isVersionMismatch)
? Positioned( Positioned(
bottom: 12, bottom: 12,
right: 12, right: 12,
child: GestureDetector( child: GestureDetector(
onTap: () => Scaffold.of(context).openDrawer(), onTap: () => Scaffold.of(context).openDrawer(),
child: Material( child: Material(
color: Colors.grey[200], color: Colors.grey[200],
elevation: 1, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0), borderRadius: BorderRadius.circular(50.0),
), ),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(2.0), padding: EdgeInsets.all(2.0),
child: Icon( child: Icon(
Icons.info, Icons.info,
color: Color.fromARGB(255, 243, 188, 106), color: Color.fromARGB(255, 243, 188, 106),
size: 15, size: 15,
),
),
), ),
), ),
) ),
: Container(), ),
),
], ],
); );
}, },
@@ -90,21 +90,21 @@ class ImmichSliverAppBar extends ConsumerWidget {
Stack( Stack(
alignment: AlignmentDirectional.center, alignment: AlignmentDirectional.center,
children: [ children: [
backupState.backupProgress == BackUpProgressEnum.inProgress if (backupState.backupProgress == BackUpProgressEnum.inProgress)
? Positioned( Positioned(
top: 10, top: 10,
right: 12, right: 12,
child: SizedBox( child: SizedBox(
height: 8, height: 8,
width: 8, width: 8,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 1, strokeWidth: 1,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor), Theme.of(context).primaryColor,
),
), ),
) ),
: Container(), ),
),
IconButton( IconButton(
splashRadius: 25, splashRadius: 25,
iconSize: 30, iconSize: 30,
@@ -119,7 +119,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
Icons.cloud_off_rounded, Icons.cloud_off_rounded,
size: 8, size: 8,
), ),
child: const Icon(Icons.backup_rounded)), child: const Icon(Icons.backup_rounded),
),
onPressed: () async { onPressed: () async {
var onPop = await AutoRouter.of(context) var onPop = await AutoRouter.of(context)
.push(const BackupControllerRoute()); .push(const BackupControllerRoute());
@@ -129,18 +130,15 @@ class ImmichSliverAppBar extends ConsumerWidget {
} }
}, },
), ),
backupState.backupProgress == BackUpProgressEnum.inProgress if (backupState.backupProgress == BackUpProgressEnum.inProgress)
? Positioned( Positioned(
bottom: 5, bottom: 5,
child: Text( child: Text(
(backupState.allUniqueAssets.length - '${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}',
backupState.selectedAlbumsBackupAssetsIds.length) style:
.toString(), const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
style: const TextStyle( ),
fontSize: 9, fontWeight: FontWeight.bold), ),
),
)
: Container()
], ],
), ),
], ],

View File

@@ -1,5 +1,5 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class MonthlyTitleText extends StatelessWidget { class MonthlyTitleText extends StatelessWidget {
const MonthlyTitleText({ const MonthlyTitleText({
@@ -11,7 +11,8 @@ class MonthlyTitleText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var monthTitleText = DateFormat('MMMM y').format(DateTime.parse(isoDate)); var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(isoDate));
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(

View File

@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
@@ -54,7 +55,8 @@ class ProfileDrawer extends HookConsumerWidget {
return CircleAvatar( return CircleAvatar(
radius: 35, radius: 35,
backgroundImage: NetworkImage( backgroundImage: NetworkImage(
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}'), '$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
); );
} else { } else {
@@ -70,7 +72,8 @@ class ProfileDrawer extends HookConsumerWidget {
return CircleAvatar( return CircleAvatar(
radius: 35, radius: 35,
backgroundImage: NetworkImage( backgroundImage: NetworkImage(
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}'), '$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
); );
} }
@@ -87,12 +90,15 @@ class ProfileDrawer extends HookConsumerWidget {
return const ImmichLoadingIndicator(); return const ImmichLoadingIndicator();
} }
return Container(); return const SizedBox();
} }
_pickUserProfileImage() async { _pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage( final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024); source: ImageSource.gallery,
maxHeight: 1024,
maxWidth: 1024,
);
if (image != null) { if (image != null) {
var success = var success =
@@ -100,16 +106,20 @@ class ProfileDrawer extends HookConsumerWidget {
if (success) { if (success) {
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath( ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
ref.read(uploadProfileImageProvider).profileImagePath); ref.read(uploadProfileImageProvider).profileImagePath,
);
} }
} }
} }
useEffect(() { useEffect(
_getPackageInfo(); () {
_buildUserProfileImage(); _getPackageInfo();
return null; _buildUserProfileImage();
}, []); return null;
},
[],
);
return Drawer( return Drawer(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -183,15 +193,16 @@ class ProfileDrawer extends HookConsumerWidget {
color: Colors.black54, color: Colors.black54,
), ),
title: const Text( title: const Text(
"Sign Out", "profile_drawer_sign_out",
style: TextStyle( style: TextStyle(
color: Colors.black54, color: Colors.black54,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold,
), ),
).tr(),
onTap: () async { onTap: () async {
bool res = bool res =
await ref.read(authenticationProvider.notifier).logout(); await ref.watch(authenticationProvider.notifier).logout();
if (res) { if (res) {
ref.watch(backupProvider.notifier).cancelBackup(); ref.watch(backupProvider.notifier).cancelBackup();
@@ -227,12 +238,13 @@ class ProfileDrawer extends HookConsumerWidget {
child: Text( child: Text(
serverInfoState.isVersionMismatch serverInfoState.isVersionMismatch
? serverInfoState.versionMismatchErrorMessage ? serverInfoState.versionMismatchErrorMessage
: "Client and Server are up-to-date", : "profile_drawer_client_server_up_to_date".tr(),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w600), fontWeight: FontWeight.w600,
),
), ),
), ),
const Divider(), const Divider(),
@@ -270,7 +282,7 @@ class ProfileDrawer extends HookConsumerWidget {
), ),
), ),
Text( Text(
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}", "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.grey[500], color: Colors.grey[500],

View File

@@ -8,11 +8,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.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/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:openapi/api.dart';
class ThumbnailImage extends HookConsumerWidget { class ThumbnailImage extends HookConsumerWidget {
final ImmichAsset asset; final AssetResponseDto asset;
const ThumbnailImage({Key? key, required this.asset}) : super(key: key); const ThumbnailImage({Key? key, required this.asset}) : super(key: key);
@@ -22,13 +22,13 @@ class ThumbnailImage extends HookConsumerWidget {
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
var selectedAsset = ref.watch(homePageStateProvider).selectedItems; var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable; var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var deviceId = ref.watch(authenticationProvider).deviceId; var deviceId = ref.watch(authenticationProvider).deviceId;
Widget _buildSelectionIcon(ImmichAsset asset) { Widget _buildSelectionIcon(AssetResponseDto asset) {
if (selectedAsset.contains(asset)) { if (selectedAsset.contains(asset)) {
return Icon( return Icon(
Icons.check_circle, Icons.check_circle,
@@ -45,14 +45,22 @@ class ThumbnailImage extends HookConsumerWidget {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
debugPrint("View ${asset.id}"); debugPrint("View ${asset.id}");
if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) { if (isMultiSelectEnable &&
selectedAsset.contains(asset) &&
selectedAsset.length == 1) {
ref.watch(homePageStateProvider.notifier).disableMultiSelect(); ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) { } else if (isMultiSelectEnable &&
ref.watch(homePageStateProvider.notifier).removeSingleSelectedItem(asset); selectedAsset.contains(asset) &&
selectedAsset.length > 1) {
ref
.watch(homePageStateProvider.notifier)
.removeSingleSelectedItem(asset);
} else if (isMultiSelectEnable && !selectedAsset.contains(asset)) { } else if (isMultiSelectEnable && !selectedAsset.contains(asset)) {
ref.watch(homePageStateProvider.notifier).addSingleSelectedItem(asset); ref
.watch(homePageStateProvider.notifier)
.addSingleSelectedItem(asset);
} else { } else {
if (asset.type == 'IMAGE') { if (asset.type == AssetTypeEnum.IMAGE) {
AutoRouter.of(context).push( AutoRouter.of(context).push(
ImageViewerRoute( ImageViewerRoute(
imageUrl: imageUrl:
@@ -65,8 +73,10 @@ class ThumbnailImage extends HookConsumerWidget {
} else { } else {
AutoRouter.of(context).push( AutoRouter.of(context).push(
VideoViewerRoute( VideoViewerRoute(
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', videoUrl:
asset: asset), '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
asset: asset,
),
); );
} }
} }
@@ -83,23 +93,32 @@ class ThumbnailImage extends HookConsumerWidget {
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: isMultiSelectEnable && selectedAsset.contains(asset) border: isMultiSelectEnable && selectedAsset.contains(asset)
? Border.all(color: Theme.of(context).primaryColorLight, width: 10) ? Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
)
: const Border(), : const Border(),
), ),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}", cacheKey: "${asset.id}-${cacheKey.value}",
width: 300, width: 300,
height: 300, height: 300,
memCacheHeight: asset.type == 'IMAGE' ? 250 : 400, memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
fit: BoxFit.cover, fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl, imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 250), fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( progressIndicatorBuilder: (context, url, downloadProgress) =>
Transform.scale(
scale: 0.2, scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress), child: CircularProgressIndicator(
value: downloadProgress.progress,
),
), ),
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error");
return Icon( return Icon(
Icons.image_not_supported_outlined, Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
@@ -107,22 +126,21 @@ class ThumbnailImage extends HookConsumerWidget {
}, },
), ),
), ),
Container( if (isMultiSelectEnable)
child: isMultiSelectEnable Padding(
? Padding( padding: const EdgeInsets.all(3.0),
padding: const EdgeInsets.all(3.0), child: Align(
child: Align( alignment: Alignment.topLeft,
alignment: Alignment.topLeft, child: _buildSelectionIcon(asset),
child: _buildSelectionIcon(asset), ),
), ),
)
: Container(),
),
Positioned( Positioned(
right: 10, right: 10,
bottom: 5, bottom: 5,
child: Icon( child: Icon(
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded, (deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
color: Colors.white, color: Colors.white,
size: 18, size: 18,
), ),

View File

@@ -26,29 +26,25 @@ class HomePage extends HookConsumerWidget {
ref.watch(homePageStateProvider).isMultiSelectEnable; ref.watch(homePageStateProvider).isMultiSelectEnable;
var homePageState = ref.watch(homePageStateProvider); var homePageState = ref.watch(homePageStateProvider);
useEffect(() { useEffect(
ref.read(websocketProvider.notifier).connect(); () {
ref.read(assetProvider.notifier).getAllAsset(); ref.read(websocketProvider.notifier).connect();
ref.watch(serverInfoProvider.notifier).getServerVersion(); ref.read(assetProvider.notifier).getAllAsset();
return null; ref.watch(serverInfoProvider.notifier).getServerVersion();
}, []); return null;
},
[],
);
void reloadAllAsset() { void reloadAllAsset() {
ref.read(assetProvider.notifier).getAllAsset(); ref.read(assetProvider.notifier).getAllAsset();
} }
_buildSelectedItemCountIndicator() { _buildSelectedItemCountIndicator() {
return isMultiSelectEnable return DisableMultiSelectButton(
? DisableMultiSelectButton( onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
onPressed: selectedItemCount: homePageState.selectedItems.length,
ref.watch(homePageStateProvider.notifier).disableMultiSelect, );
selectedItemCount: homePageState.selectedItems.length,
)
: Container();
}
_buildBottomAppBar() {
return isMultiSelectEnable ? const ControlBottomAppBar() : Container();
} }
Widget _buildBody() { Widget _buildBody() {
@@ -121,8 +117,10 @@ class HomePage extends HookConsumerWidget {
), ),
), ),
), ),
_buildSelectedItemCountIndicator(), if (isMultiSelectEnable) ...[
_buildBottomAppBar(), _buildSelectedItemCountIndicator(),
const ControlBottomAppBar(),
],
], ],
), ),
); );

View File

@@ -1,20 +1,17 @@
import 'dart:convert'; import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart';
class AuthenticationState { class AuthenticationState {
final String deviceId; final String deviceId;
final String deviceType; final DeviceTypeEnum deviceType;
final String userId; final String userId;
final String userEmail; final String userEmail;
final bool isAuthenticated; final bool isAuthenticated;
final String firstName; final String firstName;
final String lastName; final String lastName;
final bool isAdmin; final bool isAdmin;
final bool isFirstLogin; final bool shouldChangePassword;
final String profileImagePath; final String profileImagePath;
final DeviceInfoRemote deviceInfo; final DeviceInfoResponseDto deviceInfo;
AuthenticationState({ AuthenticationState({
required this.deviceId, required this.deviceId,
required this.deviceType, required this.deviceType,
@@ -24,23 +21,23 @@ class AuthenticationState {
required this.firstName, required this.firstName,
required this.lastName, required this.lastName,
required this.isAdmin, required this.isAdmin,
required this.isFirstLogin, required this.shouldChangePassword,
required this.profileImagePath, required this.profileImagePath,
required this.deviceInfo, required this.deviceInfo,
}); });
AuthenticationState copyWith({ AuthenticationState copyWith({
String? deviceId, String? deviceId,
String? deviceType, DeviceTypeEnum? deviceType,
String? userId, String? userId,
String? userEmail, String? userEmail,
bool? isAuthenticated, bool? isAuthenticated,
String? firstName, String? firstName,
String? lastName, String? lastName,
bool? isAdmin, bool? isAdmin,
bool? isFirstLoggedIn, bool? shouldChangePassword,
String? profileImagePath, String? profileImagePath,
DeviceInfoRemote? deviceInfo, DeviceInfoResponseDto? deviceInfo,
}) { }) {
return AuthenticationState( return AuthenticationState(
deviceId: deviceId ?? this.deviceId, deviceId: deviceId ?? this.deviceId,
@@ -51,7 +48,7 @@ class AuthenticationState {
firstName: firstName ?? this.firstName, firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName, lastName: lastName ?? this.lastName,
isAdmin: isAdmin ?? this.isAdmin, isAdmin: isAdmin ?? this.isAdmin,
isFirstLogin: isFirstLoggedIn ?? isFirstLogin, shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
profileImagePath: profileImagePath ?? this.profileImagePath, profileImagePath: profileImagePath ?? this.profileImagePath,
deviceInfo: deviceInfo ?? this.deviceInfo, deviceInfo: deviceInfo ?? this.deviceInfo,
); );
@@ -59,47 +56,9 @@ class AuthenticationState {
@override @override
String toString() { String toString() {
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, isFirstLoggedIn: $isFirstLogin, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)'; return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
} }
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'deviceId': deviceId});
result.addAll({'deviceType': deviceType});
result.addAll({'userId': userId});
result.addAll({'userEmail': userEmail});
result.addAll({'isAuthenticated': isAuthenticated});
result.addAll({'firstName': firstName});
result.addAll({'lastName': lastName});
result.addAll({'isAdmin': isAdmin});
result.addAll({'isFirstLogin': isFirstLogin});
result.addAll({'profileImagePath': profileImagePath});
result.addAll({'deviceInfo': deviceInfo.toMap()});
return result;
}
factory AuthenticationState.fromMap(Map<String, dynamic> map) {
return AuthenticationState(
deviceId: map['deviceId'] ?? '',
deviceType: map['deviceType'] ?? '',
userId: map['userId'] ?? '',
userEmail: map['userEmail'] ?? '',
isAuthenticated: map['isAuthenticated'] ?? false,
firstName: map['firstName'] ?? '',
lastName: map['lastName'] ?? '',
isAdmin: map['isAdmin'] ?? false,
isFirstLogin: map['isFirstLogin'] ?? false,
profileImagePath: map['profileImagePath'] ?? '',
deviceInfo: DeviceInfoRemote.fromMap(map['deviceInfo']),
);
}
String toJson() => json.encode(toMap());
factory AuthenticationState.fromJson(String source) => AuthenticationState.fromMap(json.decode(source));
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
@@ -113,7 +72,7 @@ class AuthenticationState {
other.firstName == firstName && other.firstName == firstName &&
other.lastName == lastName && other.lastName == lastName &&
other.isAdmin == isAdmin && other.isAdmin == isAdmin &&
other.isFirstLogin == isFirstLogin && other.shouldChangePassword == shouldChangePassword &&
other.profileImagePath == profileImagePath && other.profileImagePath == profileImagePath &&
other.deviceInfo == deviceInfo; other.deviceInfo == deviceInfo;
} }
@@ -128,7 +87,7 @@ class AuthenticationState {
firstName.hashCode ^ firstName.hashCode ^
lastName.hashCode ^ lastName.hashCode ^
isAdmin.hashCode ^ isAdmin.hashCode ^
isFirstLogin.hashCode ^ shouldChangePassword.hashCode ^
profileImagePath.hashCode ^ profileImagePath.hashCode ^
deviceInfo.hashCode; deviceInfo.hashCode;
} }

View File

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

View File

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

View File

@@ -1,48 +1,52 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/models/login_response.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart'; import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> { class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier(this.ref) AuthenticationNotifier(
: super( this._deviceInfoService,
this._backupService,
this._apiService,
) : super(
AuthenticationState( AuthenticationState(
deviceId: "", deviceId: "",
deviceType: "", deviceType: DeviceTypeEnum.ANDROID,
userId: "", userId: "",
userEmail: "", userEmail: "",
firstName: '', firstName: '',
lastName: '', lastName: '',
profileImagePath: '', profileImagePath: '',
isAdmin: false, isAdmin: false,
isFirstLogin: false, shouldChangePassword: false,
isAuthenticated: false, isAuthenticated: false,
deviceInfo: DeviceInfoRemote( deviceInfo: DeviceInfoResponseDto(
id: 0, id: 0,
userId: "", userId: "",
deviceId: "", deviceId: "",
deviceType: "", deviceType: DeviceTypeEnum.ANDROID,
notificationToken: "",
createdAt: "", createdAt: "",
isAutoBackup: false, isAutoBackup: false,
), ),
), ),
); );
final Ref ref; final DeviceInfoService _deviceInfoService;
final DeviceInfoService _deviceInfoService = DeviceInfoService(); final BackupService _backupService;
final BackupService _backupService = BackupService(); final ApiService _apiService;
final NetworkService _networkService = NetworkService();
Future<bool> login(String email, String password, String serverEndpoint, bool isSavedLoginInfo) async { Future<bool> login(
String email,
String password,
String serverEndpoint,
bool isSavedLoginInfo,
) async {
// Store server endpoint to Hive and test endpoint // Store server endpoint to Hive and test endpoint
if (serverEndpoint[serverEndpoint.length - 1] == "/") { if (serverEndpoint[serverEndpoint.length - 1] == "/") {
var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1); var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1);
@@ -51,12 +55,12 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Hive.box(userInfoBox).put(serverEndpointKey, serverEndpoint); Hive.box(userInfoBox).put(serverEndpointKey, serverEndpoint);
} }
// Check Server URL validity
try { try {
bool isServerEndpointVerified = await _networkService.pingServer(); _apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
if (!isServerEndpointVerified) { await _apiService.serverInfoApi.pingServer();
return false;
}
} catch (e) { } catch (e) {
debugPrint('Invalid Server Endpoint Url $e');
return false; return false;
} }
@@ -71,49 +75,73 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
// Make sign-in request // Make sign-in request
try { try {
Response res = await _networkService.postRequest(url: 'auth/login', data: {'email': email, 'password': password}); var loginResponse = await _apiService.authenticationApi.login(
LoginCredentialDto(
email: email,
password: password,
),
);
var payload = LogInReponse.fromJson(res.toString()); if (loginResponse == null) {
debugPrint('Login Response is null');
return false;
}
Hive.box(userInfoBox).put(accessTokenKey, payload.accessToken); Hive.box(userInfoBox).put(accessTokenKey, loginResponse.accessToken);
state = state.copyWith( state = state.copyWith(
isAuthenticated: true, isAuthenticated: true,
userId: payload.userId, userId: loginResponse.userId,
userEmail: payload.userEmail, userEmail: loginResponse.userEmail,
firstName: payload.firstName, firstName: loginResponse.firstName,
lastName: payload.lastName, lastName: loginResponse.lastName,
profileImagePath: payload.profileImagePath, profileImagePath: loginResponse.profileImagePath,
isAdmin: payload.isAdmin, isAdmin: loginResponse.isAdmin,
isFirstLoggedIn: payload.isFirstLogin, shouldChangePassword: loginResponse.shouldChangePassword,
); );
// Login Success - Set Access Token to API Client
_apiService.setAccessToken(loginResponse.accessToken);
if (isSavedLoginInfo) { if (isSavedLoginInfo) {
// Save login info to local storage // Save login info to local storage
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put( Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey, savedLoginInfoKey,
HiveSavedLoginInfo( HiveSavedLoginInfo(
email: email, email: email,
password: password, password: password,
isSaveLogin: true, isSaveLogin: true,
serverUrl: Hive.box(userInfoBox).get(serverEndpointKey)), serverUrl: Hive.box(userInfoBox).get(serverEndpointKey),
),
); );
} else { } else {
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey); Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
.delete(savedLoginInfoKey);
} }
} catch (e) { } catch (e) {
debugPrint("Error logging in $e");
return false; return false;
} }
// Register device info // Register device info
try { try {
Response res = await _networkService DeviceInfoResponseDto? deviceInfo =
.postRequest(url: 'device-info', data: {'deviceId': state.deviceId, 'deviceType': state.deviceType}); await _apiService.deviceInfoApi.createDeviceInfo(
CreateDeviceInfoDto(
deviceId: state.deviceId,
deviceType: state.deviceType,
),
);
if (deviceInfo == null) {
debugPrint('Device Info Response is null');
return false;
}
DeviceInfoRemote deviceInfo = DeviceInfoRemote.fromJson(res.toString());
state = state.copyWith(deviceInfo: deviceInfo); state = state.copyWith(deviceInfo: deviceInfo);
} catch (e) { } catch (e) {
debugPrint("ERROR Register Device Info: $e"); debugPrint("ERROR Register Device Info: $e");
return false;
} }
return true; return true;
@@ -121,27 +149,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> logout() async { Future<bool> logout() async {
Hive.box(userInfoBox).delete(accessTokenKey); Hive.box(userInfoBox).delete(accessTokenKey);
state = AuthenticationState( state = state.copyWith(isAuthenticated: false);
deviceId: "",
deviceType: "",
userId: "",
userEmail: "",
firstName: '',
lastName: '',
profileImagePath: '',
isFirstLogin: false,
isAuthenticated: false,
isAdmin: false,
deviceInfo: DeviceInfoRemote(
id: 0,
userId: "",
deviceId: "",
deviceType: "",
notificationToken: "",
createdAt: "",
isAutoBackup: false,
),
);
return true; return true;
} }
@@ -149,17 +157,44 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
setAutoBackup(bool backupState) async { setAutoBackup(bool backupState) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"]; var deviceId = deviceInfo["deviceId"];
var deviceType = deviceInfo["deviceType"];
DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType); DeviceTypeEnum deviceType = deviceInfo["deviceType"];
state = state.copyWith(deviceInfo: deviceInfoRemote);
DeviceInfoResponseDto updatedDeviceInfo =
await _backupService.setAutoBackup(backupState, deviceId, deviceType);
state = state.copyWith(deviceInfo: updatedDeviceInfo);
} }
updateUserProfileImagePath(String path) { updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path); state = state.copyWith(profileImagePath: path);
} }
Future<bool> changePassword(String newPassword) async {
try {
await _apiService.userApi.updateUser(
UpdateUserDto(
id: state.userId,
password: newPassword,
shouldChangePassword: false,
),
);
state = state.copyWith(shouldChangePassword: false);
return true;
} catch (e) {
debugPrint("Error changing password $e");
return false;
}
}
} }
final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) { final authenticationProvider =
return AuthenticationNotifier(ref); StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(
ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(apiServiceProvider),
);
}); });

View File

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

View File

@@ -1,10 +1,12 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
@@ -15,23 +17,30 @@ class LoginForm extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController.fromValue(TextEditingValue.empty); final usernameController =
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty); useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController(text: 'http://your-server-ip:2283'); final passwordController =
useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController =
useTextEditingController(text: 'login_form_endpoint_hint'.tr());
final isSaveLoginInfo = useState<bool>(false); final isSaveLoginInfo = useState<bool>(false);
useEffect(() { useEffect(
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey); () {
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
.get(savedLoginInfoKey);
if (loginInfo != null) { if (loginInfo != null) {
usernameController.text = loginInfo.email; usernameController.text = loginInfo.email;
passwordController.text = loginInfo.password; passwordController.text = loginInfo.password;
serverEndpointController.text = loginInfo.serverUrl; serverEndpointController.text = loginInfo.serverUrl;
isSaveLoginInfo.value = loginInfo.isSaveLogin; isSaveLoginInfo.value = loginInfo.isSaveLogin;
} }
return null; return null;
}, []); },
[],
);
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
@@ -64,12 +73,18 @@ class LoginForm extends HookConsumerWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
dense: true, dense: true,
side: const BorderSide(color: Colors.grey, width: 1.5), side: const BorderSide(color: Colors.grey, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
enableFeedback: true, enableFeedback: true,
title: const Text( title: const Text(
"Stay logged in", "login_form_save_login",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey), style: TextStyle(
), fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
).tr(),
value: isSaveLoginInfo.value, value: isSaveLoginInfo.value,
onChanged: (switchValue) { onChanged: (switchValue) {
if (switchValue != null) { if (switchValue != null) {
@@ -94,20 +109,26 @@ class LoginForm extends HookConsumerWidget {
class ServerEndpointInput extends StatelessWidget { class ServerEndpointInput extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
const ServerEndpointInput({Key? key, required this.controller}) : super(key: key); const ServerEndpointInput({Key? key, required this.controller})
: super(key: key);
String? _validateInput(String? url) { String? _validateInput(String? url) {
if (url == null) return null; if (url?.startsWith(RegExp(r'https?://')) == true) {
if (!url.startsWith(RegExp(r'https?://'))) return 'Please specify http:// or https://'; return null;
return null; } else {
return 'login_form_err_http'.tr();
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Server Endpoint URL', border: OutlineInputBorder(), hintText: 'http://your-server-ip:port'), labelText: 'login_form_endpoint_url'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_endpoint_hint'.tr(),
),
validator: _validateInput, validator: _validateInput,
autovalidateMode: AutovalidateMode.always, autovalidateMode: AutovalidateMode.always,
); );
@@ -121,9 +142,11 @@ class EmailInput extends StatelessWidget {
String? _validateInput(String? email) { String? _validateInput(String? email) {
if (email == null || email == '') return null; if (email == null || email == '') return null;
if (email.endsWith(' ')) return 'Trailing whitespace'; if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
if (email.startsWith(' ')) return 'Leading whitespace'; if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
if (email.contains(' ') || !email.contains('@')) return 'Invalid Email'; if (email.contains(' ') || !email.contains('@')) {
return 'login_form_err_invalid_email'.tr();
}
return null; return null;
} }
@@ -131,8 +154,11 @@ class EmailInput extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
decoration: decoration: InputDecoration(
const InputDecoration(labelText: 'Email', border: OutlineInputBorder(), hintText: 'youremail@email.com'), labelText: 'login_form_label_email'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_email_hint'.tr(),
),
validator: _validateInput, validator: _validateInput,
autovalidateMode: AutovalidateMode.always, autovalidateMode: AutovalidateMode.always,
); );
@@ -149,7 +175,11 @@ class PasswordInput extends StatelessWidget {
return TextFormField( return TextFormField(
obscureText: true, obscureText: true,
controller: controller, controller: controller,
decoration: const InputDecoration(labelText: 'Password', border: OutlineInputBorder(), hintText: 'password'), decoration: InputDecoration(
labelText: 'login_form_label_password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
),
); );
} }
} }
@@ -171,36 +201,47 @@ class LoginButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
visualDensity: VisualDensity.standard, visualDensity: VisualDensity.standard,
primary: Theme.of(context).primaryColor, primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50], onPrimary: Colors.grey[50],
elevation: 2, elevation: 2,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
), ),
onPressed: () async { onPressed: () async {
// This will remove current cache asset state of previous user login. // This will remove current cache asset state of previous user login.
ref.watch(assetProvider.notifier).clearAllAsset(); ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenticated = await ref var isAuthenticated =
.read(authenticationProvider.notifier) await ref.watch(authenticationProvider.notifier).login(
.login(emailController.text, passwordController.text, serverEndpointController.text, isSavedLoginInfo); emailController.text,
passwordController.text,
serverEndpointController.text,
isSavedLoginInfo,
);
if (isAuthenticated) { if (isAuthenticated) {
// Resume backup (if enable) then navigate // Resume backup (if enable) then navigate
if (ref.watch(authenticationProvider).shouldChangePassword &&
!ref.watch(authenticationProvider).isAdmin) {
AutoRouter.of(context).push(const ChangePasswordRoute());
} else {
ref.watch(backupProvider.notifier).resumeBackup(); ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/tab-controller-page"); AutoRouter.of(context).pushNamed("/tab-controller-page");
} else {
ImmichToast.show(
context: context,
msg: "Error logging you in, check server url, email and password!",
toastType: ToastType.error,
);
} }
}, } else {
child: const Text( ImmichToast.show(
"Login", context: context,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), msg: "login_form_failed_login".tr(),
)); toastType: ToastType.error,
);
}
},
child: const Text(
"login_form_button_text",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(),
);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:openapi/api.dart';
class SearchResultPageState { class SearchResultPageState {
final bool isLoading; final bool isLoading;
final bool isSuccess; final bool isSuccess;
final bool isError; final bool isError;
final List<ImmichAsset> searchResult; final List<AssetResponseDto> searchResult;
SearchResultPageState({ SearchResultPageState({
required this.isLoading, required this.isLoading,
@@ -20,7 +20,7 @@ class SearchResultPageState {
bool? isLoading, bool? isLoading,
bool? isSuccess, bool? isSuccess,
bool? isError, bool? isError,
List<ImmichAsset>? searchResult, List<AssetResponseDto>? searchResult,
}) { }) {
return SearchResultPageState( return SearchResultPageState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
@@ -35,7 +35,7 @@ class SearchResultPageState {
'isLoading': isLoading, 'isLoading': isLoading,
'isSuccess': isSuccess, 'isSuccess': isSuccess,
'isError': isError, 'isError': isError,
'searchResult': searchResult.map((x) => x.toMap()).toList(), 'searchResult': searchResult.map((x) => x.toJson()).toList(),
}; };
} }
@@ -44,13 +44,16 @@ class SearchResultPageState {
isLoading: map['isLoading'] ?? false, isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false, isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false, isError: map['isError'] ?? false,
searchResult: List<ImmichAsset>.from(map['searchResult']?.map((x) => ImmichAsset.fromMap(x))), searchResult: List<AssetResponseDto>.from(
map['searchResult']?.map((x) => AssetResponseDto.mapFromJson(x)),
),
); );
} }
String toJson() => json.encode(toMap()); String toJson() => json.encode(toMap());
factory SearchResultPageState.fromJson(String source) => SearchResultPageState.fromMap(json.decode(source)); factory SearchResultPageState.fromJson(String source) =>
SearchResultPageState.fromMap(json.decode(source));
@override @override
String toString() { String toString() {
@@ -71,6 +74,9 @@ class SearchResultPageState {
@override @override
int get hashCode { int get hashCode {
return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode; return isLoading.hashCode ^
isSuccess.hashCode ^
isError.hashCode ^
searchResult.hashCode;
} }
} }

View File

@@ -1,12 +1,11 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
import 'package:immich_mobile/modules/search/models/search_page_state.model.dart'; import 'package:immich_mobile/modules/search/models/search_page_state.model.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart'; import 'package:immich_mobile/modules/search/services/search.service.dart';
import 'package:openapi/api.dart';
class SearchPageStateNotifier extends StateNotifier<SearchPageState> { class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
SearchPageStateNotifier() SearchPageStateNotifier(this._searchService)
: super( : super(
SearchPageState( SearchPageState(
searchTerm: "", searchTerm: "",
@@ -16,7 +15,7 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
), ),
); );
final SearchService _searchService = SearchService(); final SearchService _searchService;
void enableSearch() { void enableSearch() {
state = state.copyWith(isSearchEnabled: true); state = state.copyWith(isSearchEnabled: true);
@@ -54,29 +53,22 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
final searchPageStateProvider = final searchPageStateProvider =
StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) { StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
return SearchPageStateNotifier(); return SearchPageStateNotifier(ref.watch(searchServiceProvider));
}); });
final getCuratedLocationProvider = final getCuratedLocationProvider =
FutureProvider.autoDispose<List<CuratedLocation>>((ref) async { FutureProvider.autoDispose<List<CuratedLocationsResponseDto>>((ref) async {
final SearchService searchService = SearchService(); final SearchService searchService = ref.watch(searchServiceProvider);
var curatedLocation = await searchService.getCuratedLocation(); var curatedLocation = await searchService.getCuratedLocation();
if (curatedLocation != null) { return curatedLocation ?? [];
return curatedLocation;
} else {
return [];
}
}); });
final getCuratedObjectProvider = final getCuratedObjectProvider =
FutureProvider.autoDispose<List<CuratedObject>>((ref) async { FutureProvider.autoDispose<List<CuratedObjectsResponseDto>>((ref) async {
final SearchService searchService = SearchService(); final SearchService searchService = ref.watch(searchServiceProvider);
var curatedObject = await searchService.getCuratedObjects(); var curatedObject = await searchService.getCuratedObjects();
if (curatedObject != null) {
return curatedObject; return curatedObject ?? [];
} else {
return [];
}
}); });

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