Compare commits

...

168 Commits

Author SHA1 Message Date
Alex
10239161fd fix(mobile): app crash when there is no object detection result on search page (#725)
* fix(mobile): app crash when there is no data for object detection

* Up version for release

* Up version for release
2022-09-19 11:03:51 -05:00
Alex
242f10952d fix(server): query only image (#724) 2022-09-19 10:48:44 -05:00
Alex Tran
e997bd371b Up server version 2022-09-18 21:44:55 -05:00
Alex
400167f4ef fix(server): sanitization error that crash the server (#721) 2022-09-18 21:44:13 -05:00
Alex
572f6d833d Up mobile version and update deprecated api 2022-09-18 16:11:30 -05:00
Alex
2e06be5155 Up mobile version and update deprecated api 2022-09-18 16:11:24 -05:00
Alex Tran
62121470a8 Up server version 2022-09-18 15:37:10 -05:00
Alex
e3ccc3ee6b feat(server): sanitized path for asset creation process to avoid security risk (#717)
* feat(server): sanitized path for asset creation process to avoid security risk

* Sanitize resize path
2022-09-18 15:16:53 -05:00
Alex
ece94f6bdc fix(server): correct user permission to update user info (#716) 2022-09-18 09:27:06 -05:00
Jamie Slome
03fc0703c0 Create SECURITY.md (#712) 2022-09-17 13:07:12 -05:00
Alex
0d13b25f56 feat(web): Update to latest version of SvelteKit (#705) 2022-09-16 23:13:22 -05:00
Alex
75c2067836 feat(web) Remove fetching fonts from GoogleFonts (#703) 2022-09-16 17:23:31 -05:00
Alex
824da6a07b Up server version 2022-09-16 16:55:04 -05:00
Alex
2c2ea24dc4 test(web) Add tests for asset repository (#680)
* Added back tests for asset repository

* Added more tests

* Added asset count test
2022-09-16 16:47:45 -05:00
Alex
47b73a5b64 fix(mobile): Fixed iOS 16 overflow cache and memory leaked in gallery viewer. (#700) 2022-09-16 16:46:23 -05:00
bo0tzz
6b3f8e548d Merge pull request #699 from JaCoB1123/patch-1
Fix spelling of Proxmox in Readme
2022-09-15 23:07:00 +02:00
Jan Bader
0ea483f901 Fix spelling of Proxmox in Readme 2022-09-15 23:05:15 +02:00
Jonas Janz
97aed8ef23 fix(nginx): revert nginx image to support arm/v7 (#692) 2022-09-14 13:36:29 -05:00
Alex
0ee3fe9157 Update install.sh to use latest released tag 2022-09-14 11:07:37 -05:00
Alex
434770155f Up version for release 2022-09-14 10:27:34 -05:00
Alex
7e8bf94543 fix/cache read write error ios16 (#691)
* Fix(mobile) cache read/write issue, cannot load image on ios16

* Update
2022-09-14 10:18:25 -05:00
Zack Pollard
8d8944705c Merge pull request #690 from beune/fix-typo
Fix typo
2022-09-14 13:16:47 +01:00
Pim Beune
7c9c1a5169 Fix typo 2022-09-14 13:53:34 +02:00
Jonas Janz
1a6c16d8ea breaking(setup): use non-root image for immich-proxy (#651)
* feat(nginx): use non-root container for immich-proxy

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

* re-add test env

* feat(nginx): add correct port for staging

* add the new port to the default docker-compose.yml

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-09-13 21:50:10 -05:00
Alex
ccf792f9d3 fix(server): mismatch createdAt value in table and table (#688) 2022-09-13 20:12:42 -05:00
Fynn Petersen-Frey
789bc8563c fix Android BackgroundServiceStartNotAllowedException (#687) 2022-09-13 20:12:31 -05:00
Manuel
99a50f70dd readme: add app store links (#689) 2022-09-13 18:23:27 -05:00
Alex Tran
9bef411056 Up server version: 2022-09-13 12:14:36 -05:00
Alex
e79e92c60f Added Log level to background service (#685) 2022-09-13 12:09:57 -05:00
Alex
858ad43d3b fix(server): harden inserting process, self-healing timestamp info on bad timestamp (#682)
* fix(server): harden inserting process, self-healing timestamp info
2022-09-12 23:35:44 -05:00
Alex
5761765ea7 fix(server): remove album thumbnail when the asset is deleted from the database (#681) 2022-09-12 22:06:52 -05:00
Thanh Pham
6abc733763 fix(web): datetime display and add TZ into environment (#618)
* fix(web): timezone

* doc(): update readme.md

* feat(web): keep using UTC timezone in default

* chore(): update doc and remove debug code

* chore(): update readme.md

* Move timezone into to .env.example

* Run prettier check

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-12 14:40:18 -05:00
Alex Tran
4271e24e59 Up version for release 2022-09-11 16:05:53 -05:00
Alex
9e4ed2214b fix(web): incorrect shared album count (#677) 2022-09-11 10:07:04 -05:00
Alex
011332e509 fix(mobile) memory leaked causes app to crash when swiping (#673)
* Dispose image provider when swiping away from the asset
2022-09-11 09:56:26 -05:00
Alex
5403ef4d84 Fix(mobile) oversize play button (#672) 2022-09-11 00:25:04 -05:00
Alex Tran
31739aca02 Up version for release 2022-09-10 11:58:59 -05:00
Thanh Pham
8f2e7b6f65 fix(server): loop on checksum generation (#662) 2022-09-10 11:52:39 -05:00
Brett Profitt
4ed647c43d fix(install): Fix checking for docker compose. (#663) 2022-09-10 11:48:50 -05:00
Alex
f88ff4fb5c fix(mobile): background backup not working in release mode (#664) 2022-09-10 11:46:51 -05:00
Alex Tran
cc4881d633 Up version for release 2022-09-09 23:23:37 -05:00
Alex
d856b35afc feat(web) add scrollbar with timeline information (#658)
- Implement a scrollbar with a timeline similar to Google Photos
- The scrollbar can also be dragged
2022-09-09 15:55:20 -05:00
Jaime Baez
b6d025da09 Fix Notification components possible memory leaks (#650)
Dispose subscriptions and timeouts when
the components are removed from the DOM
2022-09-09 07:40:35 -05:00
Jaime Baez
cc79ff1ca3 Merge pull request #642 from immich-app/add/ci-web-checks
Add web test / check commands and workflow to run in CI
2022-09-08 19:12:39 +02:00
Jaime Baez
131aa2b6be Add command to test/check code in dev-setup docs 2022-09-08 17:54:45 +02:00
Jaime Baez
02a6b73122 Add web-unit-test workflow to run in CI 2022-09-08 17:44:13 +02:00
Jaime Baez
d87366c095 Add dev-setup documentation 2022-09-08 17:41:24 +02:00
Jaime Baez
4f7a3afbfc Fix web lint issues 2022-09-08 17:30:49 +02:00
Jaime Baez
6725954b70 Add web check / lint npm commands
`svelte-check` returns some "hints" that can be ignored since some
are not true and others are not relevant.
2022-09-08 17:17:15 +02:00
Fynn Petersen-Frey
4fe535e5e8 improve Android background service reliability (#603)
This change greatly reduces the chance that a backup is not performed
when a new photo/video is made.
Instead of combining the change trigger and additonal constraints (wifi
or charging) into a single worker, these aspects are now separated.
Thus, it is now reliably possible to take pictures while the wifi
constraint is not satisfied and upload them hours/days later once
connected to wifi without taking a new photo.
As a positive side effect, this simplifies the error/retry handling
by directly leveraging Android's WorkManager without workarounds.
The separation also allows to notify the currently running BackupWorker
that new assets were added while backing up other assets to also upload
those newly added assets.
Further, a new tiny service checks if the app is killed, to reschedule
the content change worker and allow to detect the first new photo.
Bonus: The home screen now shows backup as enabled if background backup
is active.

* use separate worker/task for listening on changed/added assets
* use separate worker/task for performing the backup
* content observer worker enqueues backup worker on each new asset
* wifi/charging constraints only apply to backup worker
* backupworker is notified of assets added while running to re-run
* new service to catch app being killed to workaround WorkManager issue
2022-09-08 08:36:08 -05:00
Jaime Baez
aed94bfc4c Format web code with prettier
Added `.md` and `.json` to .prettierignore
2022-09-08 12:53:09 +02:00
Jaime Baez
de996c0a81 Merge pull request #612 from immich-app/add/web-ui-tests-setup
Add web UI components tests setup

@alextran1502 I'll get this merged so I can add CI checks for the web as well. Let me know if you have any questions 😃
2022-09-08 11:24:08 +02:00
Jaime Baez
1a39aa4da5 Merge pull request #633 from immich-app/fix/server-lint-errors
Add all server checks to CI - fix lint issues
2022-09-08 11:12:31 +02:00
Jaime Baez
1f4ba73da7 Add all server checks to CI - fix lint issues
CI will now run linter, type-checks and tests for the server.

All the lint issues have been fixed.
2022-09-08 11:07:27 +02:00
Alex Tran
836b174d33 Better styling for count info 2022-09-07 21:19:24 -05:00
Alex Tran
853a65aef1 Up version for release 2022-09-07 15:26:29 -05:00
Alex
566039b93f feat(web): add asset and album count info (#623)
* Get asset and album count

* Generate APIs

* Added asset count for each type

* Added api on the web

* Added info button for asset and album count to trigger getting info on hover

* Remove websocket event from photo page
2022-09-07 15:16:18 -05:00
bo0tzz
18a7ff8726 Remove empty translations (#620) 2022-09-07 14:41:44 -05:00
Thanh Pham
6ffdf167fe fix(web): detail panel overflow-x (#615) 2022-09-07 13:20:44 -05:00
Jaime Baez
6b702b13e4 Rename albums BLoC (.bloc.ts convention)
By convention now it's `album.bloc.ts`
2022-09-07 16:04:50 +02:00
Jaime Baez
f476bd985b Add AlbumCard UI tests
- add libraries for component UI testing
- implement AlbumCard UI tests
2022-09-07 16:00:57 +02:00
Alex
92c4f0598b fix(mobile): search page crashes the app on some Android models (#610) 2022-09-07 06:45:26 -05:00
Alex
a337402124 fix(web): stop showing version announcement on first run of a new web instance (#609) 2022-09-07 06:38:29 -05:00
dependabot[bot]
209e6332b3 Bump actions/checkout from 2 to 3 (#604)
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-07 06:08:44 -05:00
Jaime Baez
645bd8a109 Add web test setup (#597)
* Extract logic from Albums page

- move "albums" page logic to `albums-bloc`
- add types to AlbumCard custom events

* Implement some album-bloc unit-tests

- add libraries for testing
- add album factory
- changes in albums-bloc API

* Add rest of albums-bloc test

Cleanup and remove console logs

* Refactor `isShowContextMenu` writable to derived
2022-09-07 05:20:19 -05:00
Daniel Weaver
9a471d80f7 Update README.md (#599)
Adding a note to the installation section about reverse proxies being a cause for issues when uploading large files.
2022-09-06 16:05:38 -05:00
Alex Tran
de0c59efe7 Added mobile change log 37 2022-09-06 10:03:55 -05:00
Alex Tran
c19d26f4f3 Update some mobile UI with Material 3 theme 2022-09-06 09:37:04 -05:00
Alex Tran
2edfc75c8a Fixed sliverappbar icon color to conform with theming 2022-09-06 08:18:07 -05:00
Matthias Rupp
4c977d2c1f fix(mobile): cache related crash (#593) 2022-09-06 08:10:52 -05:00
Alex
1425f2ec78 Up server version 2022-09-05 23:44:42 -05:00
Alex
b081eda76f fix(server): change the createdAt and modifiedAt to the correct type in database (#591)
* Added migration files

* Remove type casting in sql query
2022-09-05 20:51:01 -05:00
Thanh Pham
7f6837c751 hotfix(server): skip exif extraction on duplicate file (#590)
* fix(server): skip exif extraction on duplicate file

* fix(server): typo

* chore(server): remvoe un-use code
2022-09-05 20:02:50 -05:00
Thanh Pham
a467936e73 feat(server): de-duplication (#557)
* feat(server): remove un-used deviceAssetId cols.

* feat(server): return 409 if asset is duplicated

* feat(server): replace old unique constaint

* feat(server): strip deviceId in file path

* feat(server): skip duplicate asset

* chore(server): revert changes

* fix(server): asset test spec

* fix(server): checksum generation for uploaded assets

* fix(server): make sure generation queue run after migraion

* feat(server): remove temp file

* chore(server): remove dead code
2022-09-05 14:45:38 -05:00
Alex Tran
2677ddccaa Up version for release 2022-09-05 14:32:05 -05:00
bo0tzz
564ace3ddf Use runtime env var for login page message to lower web container startup time (#577)
* Use runtime env var for loginPageMessage

* Rename VITE_LOGIN_PAGE_MESSAGE to PUBLIC_LOGIN_PAGE_MESSAGE in .env.example

* Move docker image `npm run build` step into Dockerfile

* Remove comment from web Dockerfile
2022-09-05 09:51:45 -05:00
Thanh Pham
a81ef7497c feat(server): support 3gpp format (#582)
* feat(server): support 3gpp format

* feat(web): add 3gp ext

* Support 3gp video format.

video/3gpp mimetype added to supported video format.

* feat(mobile): add tif ext

Co-authored-by: Alexandre Bouijoux <alexandre@bouijoux.fr>
2022-09-05 08:53:13 -05:00
Matthias Rupp
caa7b07398 Show all albums an asset appears in on the asset viewer page (#575)
* Add route to query albums for a specific asset

* Update API and add to detail-panel

* Fix tests

* Refactor API endpoint

* Added alt attribute to img tag

Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-09-05 08:50:20 -05:00
Alex
6976a7241e Fixed upload asset to album in asset selection (#579)
* Fixed error uploading a file from album

* Fixed album selection mode show viewing asset stage

* Navigate back after uploading asset to album
2022-09-05 00:18:53 -05:00
Alex Tran
172eda3ce5 Fixed readme 2022-09-04 21:08:13 -05:00
Alex
552340add7 Feature - Implemented virtual scroll on web (#573)
This PR implemented a virtual scroll on the web, as seen in this article.

[Building the Google Photos Web UI](https://medium.com/google-design/google-photos-45b714dfbed1)
2022-09-04 08:34:39 -05:00
SirBogner
bd92dde117 Update localizely.yml (#574) 2022-09-04 08:27:05 -05:00
Alex
617c54ab81 [Localizely] Translations update (#576) 2022-09-04 08:26:11 -05:00
Thanh Pham
c76f7804ab feat(server): generate checksum for previous uploaded assets (#558)
* feat(server): generate checksum for previous uploaded assets

* fix(server): typo
2022-09-02 08:32:21 -05:00
Damian Gomez
0799aa2c72 Italia language for Mobile App (#559)
Co-authored-by: Damian Gomez <damian.gomez@elitedivision.it>
2022-09-01 08:48:13 -05:00
Thanh Pham
b80dca74ef feat(server): calculate sha1 checksum (#525)
* feat(server): override multer storage

* feat(server): calc sha1 of uploaded file

* feat(server): add checksum into asset

* chore(server): add package-lock for mkdirp package

* fix(server): free hash stream

* chore(server): rollback this changes, not refactor here

* refactor(server): re-arrange import statement

* fix(server): make sure hash done before callback

* refactor(server): replace varchar to char for checksum, reserve pixelChecksum for future

* refactor(server): remove pixelChecksum

* refactor(server): convert checksum from string to bytea

* feat(server): add index to checksum

* refactor(): rollback package.json changes

* feat(server): remove uploaded file when progress fail

* feat(server): calculate hash in sequence
2022-08-31 09:27:17 -05:00
Thanh Pham
f5f00e0f6c fix(web): file uploading error in album page (#550)
* feat(web): show upload error notification

* fix(web): album upload issue
2022-08-31 08:12:31 -05:00
Fynn Petersen-Frey
75d2d82d05 ask user to disable battery optimizations when turning on background backup (#554)
* ask user to disable battery optimizations when turning on background backup

* remove obsolete texts/translations

* add button link to dontkillmyapp
2022-08-31 08:08:40 -05:00
Fynn Petersen-Frey
5172242f88 fix: persist WiFi + charging settings of background backup (#553) 2022-08-30 09:09:19 -05:00
Matthias Rupp
25e68cf826 Better caching for mobile (#521)
* Use custom caches in all modules

* Cache Settings

* Fix wrong key

* Create custom cache repository based on hive

* Show cache usage in settings

* Show cache sizes

* Change settings ranges and default value

* Handle cache clear by operating system

* Resolve review comments
2022-08-29 22:44:43 -05:00
be bright
e527685ebf Added korean translation for mobile app (#549)
* Added korean translation for mobile app

* Added locale to info.plist

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-08-29 08:54:40 -05:00
Thanh Pham
e745cb5e4b fix(server): parse all img formats and enrich metadata (#547)
* fix(server): use file path instead buffer to reduce memory usage

fix undefined exif data

* fix(server): parse all img formats

* feat(server): enrich metadata

* Format oneliner condition

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-08-28 15:43:31 -05:00
Thanh Pham
dfaa4969da Server - Fixed - Use file path instead buffer to reduce memory usage on EXIF extraction (#545)
fix undefined exif data
2022-08-28 11:09:24 -05:00
Alex
f980a2f27a Add asset repository and refactor asset service (#540)
* build endpoint to get asset count by month

* Added asset repository

* Added create asset

* get asset by device ID

* Added test for existing methods

* Refactor additional endpoint

* Refactor database api to get curated locations and curated objects

* Refactor get search properties

* Fixed cookies parsing for websocket

* Added API to get asset count by time group

* Remove unused code
2022-08-26 22:53:37 -07:00
Alex
6b7c97c02a Added release notes 2022-08-26 13:43:05 -07:00
Alex
fdd9f37abd Added error handling for layout.server.ts to avoid unaccessible to previous deploy instance due to changes in SvelteKit project 2022-08-26 11:30:45 -07:00
Alex
a09bba454c Pump version for release 2022-08-26 10:57:12 -07:00
Alex
4be9aa091b Added error handling notification (#536) 2022-08-26 10:36:41 -07:00
Alex
33b810de74 Removed upload button on sharing and album page 2022-08-26 10:05:15 -07:00
Alex
44ccb1eec1 Added timeout option for notification component 2022-08-26 10:01:47 -07:00
Alex
bef38c670c Reference CLI in limit upload message 2022-08-26 09:42:48 -07:00
Alex
025d7bf192 Merge branch 'main' of github.com:immich-app/immich 2022-08-26 09:42:17 -07:00
Alex
5ad2d62039 Added limit on total of file upload on web (#535) 2022-08-26 09:39:28 -07:00
Alex
a128833e68 Added limit on total of file upload on web 2022-08-26 09:36:54 -07:00
Alex
87f7b0849a Added migration down for change exif file type 2022-08-26 09:13:11 -07:00
Alex
4596a8ee01 Change fileSizeInByte to bigint from int to handle large size (#534) 2022-08-26 09:07:59 -07:00
Alex
f9b1b12b10 Implement notification box for web (#533)
* Added test button

* styling notification box

* Added auto dismission and animation to each notificaiont list

* Remove test button
2022-08-25 23:04:23 -07:00
Alex
68b1655e7f Show the first two letter of user first and last name when profile image not existed (#532)
* Added user first name and last name abbreviation to Circle Avatar:

* Remove unsued code
2022-08-25 15:52:11 -07:00
Alex
658b64df74 Added page navigation progress indicator 2022-08-25 13:02:36 -07:00
Alex
e344503834 Fixed navigating with keyboard skip assets (#531)
* Cleaned up event listner
2022-08-24 22:18:28 -07:00
Alex
bf2760ffef Fixed mobile timeline crash when date group cannot be parsed (#530)
* Handle error when datetime is incorrect

* Added better debug message
2022-08-24 21:31:20 -07:00
Alex
db2ed2d881 Migrate SvelteKit to the latest version 431 (#526) 2022-08-24 21:10:48 -07:00
Thanh Pham
fb0fa742f5 fix(web): buffering for video player (#520)
* fix(web): buffering for video player

* chore(): missing file -_-

* refactor(web): using URL builder

* chore(): add semicolon

* fix(web): video player

* remove deadcode

Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-08-23 20:21:41 -07:00
Thanh Pham
3b55cdc0be refactor(server): move constant into common package (#522)
* refactor(server): move constant into common package

* refactor(server): re-arrange import statement in microservice module

* refactor(server): move app.config into common package

* fix(server): e2e testing
2022-08-23 07:34:21 -07:00
Alex
0efcc99f3e Added Dutch locale 2022-08-22 12:52:24 -07:00
Nick Pieper
7a85164a1e Added dutch translation for Immich (#519)
* Create nl-NL.json with dutch translation

* Add nl-NL to localizely.yml
2022-08-22 12:50:56 -07:00
Thanh Pham
ba2cda8955 feat(server): support tiff uploading (#513)
* feat(server): suport tiff uploading

* remove unused variable

Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-08-22 12:49:17 -07:00
Alex
9048be4c8e Added Code of conduct 2022-08-21 12:43:56 -07:00
Alex
83716ae1bc Added changelog note 2022-08-21 12:30:15 -07:00
Alex
5cd4d2d158 Added condition to show notification setting on android only 2022-08-21 11:04:01 -07:00
Alex
13bb6d469b Pump version for release 2022-08-21 09:56:52 -07:00
Matthias Rupp
8e4c4c34e4 Use CachedNetworkImage and separate cache for thumbnails on library page (#509)
* Use CachedNetworkImage and separate cache for thumbnails on library page

* Use caching for shared albums as well

* Introduce cache service
2022-08-21 09:41:36 -07:00
Fynn Petersen-Frey
3125d04f32 show notifications on background backup errors (#496)
* show notifications on background backup errors

* settings page to configure (background backup error) notifications

* persist time since failed background backup

* fix darkmode slider color
2022-08-21 09:29:24 -07:00
Alex
c436c57cc9 Fixed immich-machine-learning container not starting correctly in production 2022-08-20 23:04:10 -07:00
Thanh Pham
7f9f825589 fix(server): correct media info (#508)
* fix(server): correct media info

* fix(server): video metadata
2022-08-20 22:58:47 -07:00
Alex
da9aed5c11 Fixed e2e container stage 2022-08-20 22:37:55 -07:00
Alex
10ef3509dd Fixed machine-learning container cannot start prod 2022-08-20 22:27:25 -07:00
Alex
3dc538f9e6 Fixed machine-learning container cannot start prod 2022-08-20 22:26:47 -07:00
Thanh Pham
1e29ff322d build(server): minimal container (#506)
* build(server): update Dockerfile

* build(server): fix dockerfile

* build(machine-learning): multiple build stages

* build(server): update Dockerfile
2022-08-20 21:19:02 -07:00
Thanh Pham
9c30d58b10 feat(server): preserve caption fields and extract mediainfo for video (#505)
* feat(server): preserve caption fields and extract mediainfo for video

* Fixed Geocoding missing info leads to fail EXIF extraction for the whole file

Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-08-20 16:31:37 -07:00
Matthias Rupp
013a0f8324 Customization options for asset grid (#498)
* Add settings options for number of assets per row and storage indicator

* Add attributes to enum to avoid duplicate code

* Also apply customizations to albums

* Minor Refactorings

* Three stage loading i18n fix
2022-08-20 14:19:40 -07:00
Alex
07b58f46f9 Merge branch 'main' of github.com:alextran1501/immich 2022-08-20 08:01:59 -07:00
Alex
566e118a19 Added pt-BR translation locale to mobile app 2022-08-20 08:01:52 -07:00
Oton
0e18c88534 pt-BR Translation: Translation into Portuguese Brazil. (#500) 2022-08-20 08:01:25 -07:00
Alex
068d06b9ee Add x-adobe-dng to support file type (#504) 2022-08-20 07:50:58 -07:00
Thanh Pham
0cf7606ec9 fix(server): remove albumThumbnailAssetId when album is empty (#495) 2022-08-19 11:47:14 -07:00
Alex
25338ce02f Fixed issue with filename is blank on iOS causing update to fail 2022-08-18 16:27:08 -05:00
Alex Tran
4805d86a7c pump version 2022-08-18 15:01:45 -05:00
Fynn Petersen-Frey
33b1410d82 upload new photos in background with a service (#382)
* properly done background backup service

* new concurrency/locking management with heartbeat

fix communication erros with Kotlin plugin on start/stop service methods

better error handling for BackgroundService public methods

Add default notification message when service is running

* configurable WiFi & charging requirement for service

* use translations in background service
2022-08-18 09:41:59 -05:00
r3nor
f35ebec7c6 Readme redistribution and reorganisation (#485)
* redistribute and organize readme

* Fix typos

* Change developers to development

* Remove mobile app emoji

* Multiple changes

- Removed the .env example inbody to link to the .env.example file; this way it won't be needed to change the readme if the .env file changes and the readme gets shorter.
- Redistributed the steps:
  - Inline instead of tables
  - bullets in steps
- Fix wording
2022-08-18 08:31:00 -05:00
Thanh Pham
3aa6ee0320 feat: remove webp on asset deleted as well (#489)
* fix(server): remove webp file on asset deleted

* chore(server): job not fail when file not found
2022-08-18 08:25:03 -05:00
Thanh Pham
cdb0aa00d8 feat(server, microservices): add bull prefix (#490) 2022-08-18 08:24:07 -05:00
Alex
9de7b8d3a7 Create github-repo-stats.yml 2022-08-16 22:56:20 -05:00
Max
4a28a46612 fix spelling mistakes (#479) 2022-08-16 13:50:45 -05:00
Malte Kiefer
16561d15ff Added German translation for the settings view (#478)
* added translation for the settings view

* added translation for the settings view
2022-08-16 11:00:48 -05:00
Alex
9642ad2820 Fixed Websocket not getting correct data on mobile 2022-08-15 23:43:12 -05:00
Alex
e2169a26c2 Remove padding bottom on photos page mobile 2022-08-15 23:11:54 -05:00
Alex
f697922f32 Remove hardcode UIsystemLight in info.plist (#475) 2022-08-15 23:10:51 -05:00
Alex Tran
1390d01763 Up version 2022-08-15 19:13:51 -05:00
Alex
86f780871c Fixed different lettercases in email create different user (#470)
* Fixed different lettercases in email create different user

* Fixed test
2022-08-15 19:11:08 -05:00
Alex
c1b22125fd Add mobile dark mode and user setting (#468)
* styling light and dark theme

* Icon topbar

* Fixed app bar title dark theme

* Fixed issue with getting thumbnail for things

* Refactor sharing page

* Refactor scroll thumb

* Refactor chip in auto  backup indiation button

* Refactor sharing page

* Added theme toggle

* Up version for testflight build

* Refactor backup controller page

* Refactor album selection page

* refactor album pages

* Refactor gradient color profile header

* Added theme switcher

* Register app theme correctly

* Added locale to the app

* Added translation key

* Styling for bottomsheet colors

* up server version

* Fixed font size

* Fixed overlapsed sliverappbar on photos screen
2022-08-15 18:53:30 -05:00
Alex
30f069a5db Add settings screen on mobile (#463)
* Refactor profile drawer to sub component

* Added setting page, routing with some options

* Added setting service

* Implement three stage settings

* get app setting for three stage loading
2022-08-13 15:51:09 -05:00
bo0tzz
2bf6cd9241 Fix redirect to login page after password change (#461)
* Fix redirect to login page after password change

Copied from the similar fix in #414

* Fix typo in change-password form

* Remove misplaced text from user management page
2022-08-13 09:54:29 -05:00
Alex Tran
87d2a954a3 Fixed error handling with catch block 2022-08-12 22:29:24 -05:00
Alex
a388c5a642 Fixed webp upload on web (#460) 2022-08-12 21:52:30 -05:00
Alex Tran
4b34f017ca cosmetic change 2022-08-12 21:19:54 -05:00
Alex Tran
5c1d1dd5a1 Added version note for f-droid 2022-08-12 20:10:00 -05:00
Alex Tran
1580d27c23 Up version 2022-08-12 20:06:45 -05:00
Alex
4b9187928c Edit user on the web (#458)
* Added dispatch event for edit user

* Fixed import location

* solve merge conflict

* Fixed issue not admin user can access admin page

* Implemented edit user and password reset
2022-08-12 14:25:19 -05:00
Alex Tran
5b7236f6ad Temporary remove bug tests 2022-08-11 23:17:09 -05:00
Alex Tran
6fb439b580 Fixed merge conflict 2022-08-11 13:46:42 -05:00
Alex Tran
a8334b5c27 Fixed test again 2022-08-11 13:46:11 -05:00
Alex Tran
e1cac93945 Fixed test 2022-08-11 09:29:53 -05:00
R0GGER
081f9f5bce typo (#456) 2022-08-11 08:33:44 -05:00
Alex Tran
25ccc5660d Merge branch 'main' of github.com:immich-app/immich 2022-08-11 08:27:48 -05:00
Alex Tran
b6d3e578f2 Added test and github action for unit tests 2022-08-11 08:27:44 -05:00
Matthias Rupp
52377c2dcf Fix sharing on iPad (#453) 2022-08-11 08:13:33 -05:00
Alex
5c78f707fe Modify Album API endpoint to return a count attribute instead of a full assets array (#454)
* Change API to return assets count and change web behavior accordingly

* Refactor assets.length

* Explicitly declare type of assetCount so Dart SDK understand it

* Finished refactoring on mobile
2022-08-10 22:48:25 -05:00
375 changed files with 26734 additions and 4933 deletions

19
.github/workflows/github-repo-stats.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: github-repo-stats
on:
schedule:
# Run this once per day, towards the end of the day for keeping the most
# recent data point most meaningful (hours are interpreted in UTC).
- cron: "0 23 * * *"
workflow_dispatch: # Allow for running this manually.
jobs:
j1:
name: github-repo-stats
runs-on: ubuntu-latest
steps:
- name: run-ghrs
# Use latest release.
uses: jgehrcke/github-repo-stats@RELEASE
with:
ghtoken: ${{ secrets.GHRS_GITHUB_API_TOKEN }}

View File

@@ -2,17 +2,40 @@ name: Test
on:
workflow_dispatch:
pull_request:
push: { branches: master }
push:
branches: [main]
jobs:
test-server-e2e:
name: Run test suite
e2e-tests:
name: Run end-to-end test suites
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
- 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
server-unit-tests:
name: Run server unit test suites and checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run tests
run: cd server && npm ci && npm run check:all
web-unit-tests:
name: Run web unit test suites and checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run tests
run: cd web && npm ci && npm run check:all

134
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,134 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation
in our community a harassment-free experience for everyone, regardless
of age, body size, visible or invisible disability, ethnicity, sex
characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance,
race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open,
welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for
our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our
mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or
political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in
a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our
standards of acceptable behavior and will take appropriate and fair
corrective action in response to any behavior that they deem
inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned to this Code of Conduct, and will
communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also
applies when an individual is officially representing the community in
public spaces. Examples of representing our community include using an
official e-mail address, posting via an official social media account,
or acting as an appointed representative at an online or offline
event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported to the community leaders responsible for enforcement
at our Discord channel. All complaints
will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and
security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in
determining the consequences for any action they deem in violation of
this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior
deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders,
providing clarity around the nature of the violation and an
explanation of why the behavior was inappropriate. A public apology
may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued
behavior. No interaction with the people involved, including
unsolicited interaction with those enforcing the Code of Conduct, for
a specified period of time. This includes avoiding interactions in
community spaces as well as external channels like social
media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards,
including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or
public communication with the community for a specified period of
time. No public or private interaction with the people involved,
including unsolicited interaction with those enforcing the Code of
Conduct, is allowed during this period. Violating these terms may lead
to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of
community standards, including sustained inappropriate behavior,
harassment of an individual, or aggression toward or disparagement of
classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor
Covenant][homepage], version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of
conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the
FAQ at https://www.contributor-covenant.org/faq. Translations are
available at https://www.contributor-covenant.org/translations.

View File

@@ -1,6 +1,9 @@
dev:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-new:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-update:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans

255
README.md
View File

@@ -1,3 +1,9 @@
<h1 align="center"> Immich </h1>
<p align="center"> <b>High performance self-hosted photo and video backup solution.</b> </p>
<p align="center">
<img src="design/feature-panel.png" title="Immich Logo">
</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://github.com/alextran1502/immich"><img src="https://img.shields.io/github/stars/alextran1502/immich.svg?style=for-the-badge&logo=github&color=3F51B5&label=Stars&logoColor=000000&labelColor=ececec" alt="Star on Github"></a>
@@ -14,53 +20,26 @@
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
</a>
<br/>
<br/>
<br/>
<br/>
<p align="center">
<img src="design/feature-panel.png" title="Immich Logo">
</p>
<br/>
</p>
# Immich
**High performance self-hosted photo and video backup solution.**
![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
Loading ~4000 images/videos
## Screenshots
### Mobile
<p align="left">
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
<img src="design/backup-screen.png" width="150" title="Backup Setting Info">
<img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info">
<img src="design/home-screen.jpeg" width="150" title="Home Screen">
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
<img src="design/shared-albums.png" width="150" title="Shared Albums">
<img src="design/nsc6.png" width="150" title="EXIF Info">
</p>
### Web
<p align="left">
<img src="design/web-home.jpeg" width="49%" title="Home Dashboard">
<img src="design/web-detail.jpeg" width="49%" title="Detail">
</p>
# Note
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!**
This project is under heavy development, there will be continuous functions, features and api changes.
## Content
- [Features](#features)
- [Screenshots](#screenshots)
- [Installation](#installation)
- [Update](#update)
- [Mobile App](#-mobile-app)
- [Development](#development)
- [Support](#support)
- [Known Issues](#known-issues)
# Features
| | Mobile | Web |
| - | - | - |
| Upload and view videos and photos | Yes | Yes
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development, there will be continuous functions, features and api changes.
| Features | Mobile | Web |
| - | - | - |
| Upload and view videos and photos | Yes | Yes
| Auto backup when the app is opened | Yes | N/A
| Selective album(s) for backup | Yes | N/A
| Download photos and videos to local device | Yes | Yes
@@ -72,17 +51,38 @@ This project is under heavy development, there will be continuous functions, fea
| Metadata view (EXIF, map) | Yes | Yes
| Search by metadata, objects and image tags | Yes | No
| Administrative functions (user management) | N/A | Yes
| Background backup | Android | N/A
| Virtual scroll | N/A | Yes
# System Requirement
<br/>
**OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
# Screenshots
**RAM**: At least 2GB, preffered 4GB.
### Mobile
| | | | | |
| - | - | - | - | - |
| <img src="design/login-screen.png" width="150" title="Login With Custom URL"> <p align="center"> Login with custom URL </p> | <img src="design/backup-screen.png" width="150" title="Backup Setting Info"> <p align="center"> Backup Settings </p> | <img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info"> <p align="center"> Backup selection </p> | <img src="design/home-screen.jpeg" width="150" title="Home Screen"> <p align="center"> Home Screen </p> | <img src="design/search-screen.jpeg" width="150" title="Curated Search Info"> <p align="center"> Curated search </p> |
| <img src="design/shared-albums.png" width="150" title="Shared Albums"> <p align="center"> Shared albums </p> | <img src="design/nsc6.png" width="150" title="EXIF Info"> <p align="center"> EXIF info </p> | <img src="https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif" width="150" title="Loading ~4000 images/videos"> <p align="center"> Loading ~4000 images/videos </p> |
**Core**: At least 2 cores, preffered 4 cores.
### Web
| Home Dashboard | Image view |
| - | - |
|<img src="design/web-home.jpeg" width="100%" title="Home Dashboard"> | <img src="design/web-detail.jpeg" width="100%" title="Detail">|
# Technology Stack
<br/>
# Project Details
## 💾 System Requirements
- **OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
- **RAM**: At least 2GB, preferred 4GB.
- **Core**: At least 2 cores, preferred 4 cores.
## 🔩 Technology Stack
There are several services that compose Immich:
@@ -93,15 +93,20 @@ There are several services that compose Immich:
5. **Nginx** - Load balancing and optimized file uploading.
6. **TensorFlow** - Object Detection (COCO SSD) and Image Classification (ImageNet).
# Installing
## One-step installation - for evaluating only
<br/>
# Installation
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
## Testing One-step installation (not recommended for production)
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
*Applicable system: Ubuntu, Debian, MacOS*
*This installation method is for evaluating Immich before futher customization to meet the users' needs.*
In the shell, from the directory of your choice, run the following command:
- In the shell, from the directory of your choice, run the following command:
```bash
curl -o- https://raw.githubusercontent.com/immich-app/immich/main/install.sh | bash
@@ -114,116 +119,79 @@ The web application will be available at `http://<machine-ip-address>:2283`, and
The directory which is used to store the backup file is `./immich-app/immich-data`.
## Customize installation - for production usage
<br/>
## Custom installation (Recommended)
### Step 1 - Download necessary files
Create a directory called `immich-app` and cd into it. Then
- Create a directory called `immich-app` and cd into it.
Get `docker-compose.yml`
- Get `docker-compose.yml`
```bash
wget https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml
```
Get `.env`
- Get `.env`
```bash
wget -O .env wget https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example
wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example
```
### Step 2 - Populate .env file with customed information
### Step 2 - Populate .env file with custom information
* Populate customised database information if necessary.
<a href="https://github.com/immich-app/immich/blob/main/docker/.env.example" target="_blank"><b>See the example <code>.env</code> file</b></a>
* Populate custom database information if necessary.
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`
* [Optional] Populate Mapbox value.
**Example**
```bash
###################################################################################
# Database
###################################################################################
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=immich
###################################################################################
# Upload File Config
###################################################################################
UPLOAD_LOCATION=<put-the-path-of-the-upload-folder-here>
###################################################################################
# JWT SECRET
###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
###################################################################################
# MAPBOX
####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
MAPBOX_KEY=
```
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
* [Optional] Populate Mapbox value to use reverse geocoding.
* [Optional] Populate `TZ` as your timezone, default is `Etc/UTC`.
### Step 3 - Start the containers
Run `docker-compose up` or `docker compose up` (based on your docker's version)
- Run `docker-compose up` or `docker compose up` (based on your docker's version)
### Step 4 - Register admin user
Navigate to the web at `http://<machine-ip-address>:2283` and follow the prompts to register admin user.
<p align="left">
- Navigate to the web at `http://<machine-ip-address>:2283` and follow the prompts to register admin user.
<p align="center">
<img src="design/admin-registration-form.png" width="300" title="Admin Registration">
<p/>
</p>
Additional accounts on the server can be created by the admin account.
<p align="left">
<img src="design/admin-interface.png" width="500" title="Admin User Management">
<p/>
- You can add and manage users from the administration page.
<p align="center">
<img src="design/admin-interface.png" width="500" title="Admin User Management">
</p>
### Step 5 - Access the mobile app
Login the mobile app with the server endpoint URL at `http://<machine-ip-address>:2283/api`
<p align="left">
- Login the mobile app with the server endpoint URL at `http://<machine-ip-address>:2283/api`
<p align="center">
<img src="design/login-screen.jpeg" width="250" title="Example login screen">
<p/>
</p>
## Mobile app
<br/>
## F-Droid
You can get the app on F-droid by clicking the image below.
## Update
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/app.alextran.immich)
If you have installed, you can update the application by navigate to the directory that contains the `docker-compose.yml` file and run the following command:
```bash
docker-compose pull && docker-compose up -d
```
# Mobile app
| F-Droid | Google Play | iOS |
| - | - | - |
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
## Android
#### Get the app on Google Play Store [here](https://play.google.com/store/apps/details?id=app.alextran.immich)
*The App version might be lagging behind the latest release due to the review process.*
<p align="left">
<img src="design/google-play-qr-code.png" width="200" title="Google Play Store">
<p/>
## iOS
#### Get the app on Apple AppStore [here](https://apps.apple.com/us/app/immich/id1613945652):
*The App version might be lagging behind the latest release due to the review process.*
<p align="left">
<img src="design/ios-qr-code.png" width="200" title="Apple App Store">
<p/>
<br/>
# Development
@@ -244,35 +212,32 @@ 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.
<br/>
# 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 [**one time**](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or monthly donation from [**Github Sponsor**](https://github.com/sponsors/alextran1502)
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 [**one time**](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or monthly donation from [**Github Sponsor**](https://github.com/sponsors/alextran1502).
You can also donate using crypto currency with the following addresses:
<p align="left" style="display: flex; place-items: center; gap: 20px" title="Bitcoin(BTC)">
<img src="design/bitcoin.png" width="25" title="Bitcoin">
<code>1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX</code>
</p>
<p align="" style="display: flex; place-items: center; gap: 15px" title="Bitcoin(BTC)"><img src="design/bitcoin.png" width="25" title="Bitcoin"> <b>Bitcoin</b>: <code>1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX</code></p>
<p align="left" style="display: flex; place-items: center; gap: 15px" title="Cardano(ADA)">
<img src="design/cardano.png" width="30" title="Cardano">
<code>
addr1qyy567vqhqrr3p7vpszr5p264gw89sqcwts2z8wqy4yek87cdmy79zazyjp7tmwhkluhk3krvslkzfvg0h43tytp3f5q49nycc
</code>
</p>
<p align="" style="display: flex; place-items: center; gap: 15px" title="Cardano(ADA)"> <img src="design/cardano.png" width="30" title="Cardano"> <b>Cardano</b>: <code>addr1qyy567vqhqrr3p7vpszr5p264gw89sqcwts2z8wqy4yek87cdmy79zazyjp7tmwhkluhk3krvslkzfvg0h43tytp3f5q49nycc</code> </p>
This is also a meaningful way to give me motivation and encouragement to continue working on the app.
Cheers! 🎉
# Known Issue
<br/>
# Known Issues
## TensorFlow Build Issue
*This is a known issue for incorrect Promox setup*
*This is a known issue for incorrect Proxmox 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`:
@@ -280,7 +245,7 @@ TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX a
more /proc/cpuinfo | grep flags
```
If you are running virtualization in Promox, the VM doesn't have the flag enabled.
If you are running virtualization in Proxmox, the VM doesn't have the flag enabled.
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `alex.tran1502@gmail.com`

32
dev-setup.md Normal file
View File

@@ -0,0 +1,32 @@
# Development Setup
## Lint / format extensions
Setting these in the IDE give a better developer experience auto-formatting code on save and providing instant feedback on lint issues.
### VSCode
Install Prettier, ESLint and Svelte extensions.
in User `settings.json` (`cmd + shift + p` and search for Open User Settings JSON) add the following:
```json
{
"editor.formatOnSave": true,
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2
},
"svelte.enable-ts-plugin": true,
"eslint.validate": ["javascript", "svelte"]
}
```
## Running tests / checks
In both server and web:
`npm run check:all`

View File

@@ -36,6 +36,11 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Log message level - [simple|verbose]
###################################################################################
LOG_LEVEL=simple
###################################################################################
@@ -61,6 +66,14 @@ MAPBOX_KEY=
####################################################################################
# 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>"
# For example PUBLIC_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=
PUBLIC_LOGIN_PAGE_MESSAGE=
# For correctly display your local time zone on the web, you can set the time zone here.
# Should work fine by default value, however, in case of incorrect timezone in EXIF, this value
# should be set to the correct timezone.
# Command to get timezone:
# - Linux: curl -s http://ip-api.com/json/ | grep -oP '(?<=timezone":")(.*?)(?=")'
# TZ=Etc/UTC

View File

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

View File

@@ -6,6 +6,7 @@ services:
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run start:dev immich
volumes:
- ../server:/usr/src/app
@@ -24,6 +25,7 @@ services:
build:
context: ../machine-learning
dockerfile: Dockerfile
target: builder
command: npm run start:dev
volumes:
- ../machine-learning:/usr/src/app
@@ -41,6 +43,7 @@ services:
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run start:dev microservices
volumes:
- ../server:/usr/src/app
@@ -99,8 +102,7 @@ services:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:80
- 2284:443
- 2283:8080
logging:
driver: none
depends_on:

View File

@@ -72,8 +72,7 @@ services:
container_name: immich_proxy
image: altran1502/immich-proxy:staging
ports:
- 2283:80
- 2284:443
- 2283:8080
logging:
driver: none
depends_on:

View File

@@ -6,6 +6,7 @@ services:
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run test:e2e
expose:
- "3000"

View File

@@ -47,6 +47,8 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
environment:
- PUBLIC_TZ=${TZ}
restart: always
redis:
@@ -72,7 +74,7 @@ services:
container_name: immich_proxy
image: altran1502/immich-proxy:release
ports:
- 2283:80
- 2283:8080
logging:
driver: none
depends_on:

View File

@@ -2,12 +2,17 @@ echo "Starting Immich installation..."
ip_address=$(hostname -I | awk '{print $1}')
release_version=$(curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" |
grep '"tag_name":' |
sed -E 's/.*"([^"]+)".*/\1/')
RED='\033[0;31m'
GREEN='\032[0;31m'
NC='\033[0m' # No Color
machine_has() {
type "$1" >/dev/null 2>&1
get_release_version() {
curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" | # Get latest release from GitHub api
grep '"tag_name":' | # Get tag line
sed -E 's/.*"([^"]+)".*/\1/' # Pluck JSON value
}
create_immich_directory() {
@@ -17,12 +22,12 @@ create_immich_directory() {
download_docker_compose_file() {
echo "Downloading docker-compose.yml..."
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
}
download_dot_env_file() {
echo "Downloading .env file..."
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
}
populate_upload_location() {
@@ -45,18 +50,21 @@ populate_upload_location() {
start_docker_compose() {
echo "Starting Immich's docker containers"
if machine_has "docker compose"; then {
docker compose up --remove-orphans -d
show_friendly_message
exit 0
}; fi
if machine_has "docker-compose"; then
docker-compose up --remove-orphans -d
if docker compose &>/dev/null; then
docker_bin="docker compose"
elif docker-compose &>/dev/null; then
docker_bin="docker-compose"
else
echo 'Cannot find `docker compose` or `docker-compose`.'
exit 1
fi
if $docker_bin up --remove-orphans -d; then
show_friendly_message
exit 0
else
echo "Could not start. Check for errors above."
exit 1
fi
}
@@ -65,7 +73,7 @@ show_friendly_message() {
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
echo "The backup (or upload) location is $upload_location"
echo "---------------------------------------------------"
echo "If you want to confgure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,

View File

@@ -9,6 +9,14 @@ upload:
locale_code: de-DE
- file: mobile/assets/i18n/fr-FR.json
locale_code: fr-FR
- file: mobile/assets/i18n/it-IT.json
locale_code: it-IT
- file: mobile/assets/i18n/nl-NL.json
locale_code: nl-NL
- file: mobile/assets/i18n/ko-KR.json
locale_code: ko-KR
- file: mobile/assets/i18n/da-DK.json
locale_code: da-DK
download:
files:
- file: mobile/assets/i18n/en-US.json
@@ -17,3 +25,11 @@ download:
locale_code: de-DE
- file: mobile/assets/i18n/fr-FR.json
locale_code: fr-FR
- file: mobile/assets/i18n/it-IT.json
locale_code: it-IT
- file: mobile/assets/i18n/nl-NL.json
locale_code: nl-NL
- file: mobile/assets/i18n/ko-KR.json
locale_code: ko-KR
- file: mobile/assets/i18n/da-DK.json
locale_code: da-DK

View File

@@ -1,4 +1,5 @@
FROM node:16-bullseye-slim
# Build stage
FROM node:16-bullseye-slim as builder
ARG DEBIAN_FRONTEND=noninteractive
@@ -15,3 +16,27 @@ RUN npm rebuild @tensorflow/tfjs-node --build-from-source
COPY . .
RUN npm run build
# Prod stage
FROM node:16-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
COPY entrypoint.sh ./
RUN mkdir -p /usr/src/app/dist \
&& mkdir -p /usr/src/app/node_modules \
&& apt-get update \
&& apt-get install -y ffmpeg \
&& rm -rf /var/cache/apt/lists
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production
# CMD [ "node", "dist/main" ]

View File

@@ -1,2 +1,3 @@
# npm run typeorm migration:run
npm run build && npm run start:prod
# npm run start:prod
node dist/main.js

View File

@@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
# Fastlane
/fastlane/report.xml

View File

@@ -51,7 +51,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.alextran.immich"
minSdkVersion 21
minSdkVersion 23
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@@ -80,5 +80,8 @@ flutter {
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
implementation "com.google.guava:guava:$guava_version"
}

View File

@@ -12,6 +12,7 @@
</intent-filter>
</activity>
<service android:name=".AppClearedService" android:stopWithTask="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />

View File

@@ -1,6 +0,0 @@
package com.example.immich_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}

View File

@@ -0,0 +1,25 @@
package app.alextran.immich
import android.app.Service
import android.content.Intent
import android.os.IBinder
/**
* Catches the event when either the system or the user kills the app
* (does not apply on force close!)
*/
class AppClearedService() : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
return START_NOT_STICKY;
}
override fun onTaskRemoved(rootIntent: Intent) {
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
stopSelf();
}
}

View File

@@ -0,0 +1,75 @@
package app.alextran.immich
import android.content.Context
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Android plugin for Dart `BackgroundService`
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
*/
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
private var context: Context? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
}
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onDetachedFromEngine()
}
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!!
when(call.method) {
"enable" -> {
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true)
}
"configure" -> {
val args = call.arguments<ArrayList<*>>()!!
val requireUnmeteredNetwork = args.get(0) as Boolean
val requireCharging = args.get(1) as Boolean
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
result.success(true)
}
"disable" -> {
ContentObserverWorker.disable(ctx)
BackupWorker.stopWork(ctx)
result.success(true)
}
"isEnabled" -> {
result.success(ContentObserverWorker.isEnabled(ctx))
}
"isIgnoringBatteryOptimizations" -> {
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
}
else -> result.notImplemented()
}
}
}
private const val TAG = "BackgroundServicePlugin"

View File

@@ -0,0 +1,328 @@
package app.alextran.immich
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker
import androidx.work.NetworkType
import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkInfo
import com.google.common.util.concurrent.ListenableFuture
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation
import java.util.concurrent.TimeUnit
/**
* Worker executed by Android WorkManager to perform backup in background
*
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic.
* Called by Android WorkManager when all constraints for the work are met,
* i.e. battery is not low and optionally Wifi and charging are active.
*/
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
private val resolvableFuture = ResolvableFuture.create<Result>()
private var engine: FlutterEngine? = null
private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var timeBackupStarted: Long = 0L
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork")
val ctx = applicationContext
if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create a Notification channel if necessary
createChannel()
}
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
setForegroundAsync(createForegroundInfo(title))
} else {
showBackgroundInfo(title)
}
engine = FlutterEngine(ctx)
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
runDart()
}
return resolvableFuture
}
/**
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic.
*/
private fun runDart() {
val callbackDispatcherHandle = applicationContext.getSharedPreferences(
SHARED_PREF_NAME, Context.MODE_PRIVATE).getLong(SHARED_PREF_CALLBACK_KEY, 0L)
val callbackInformation = FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle)
val appBundlePath = flutterLoader.findAppBundlePath()
engine?.let { engine ->
backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel")
backgroundChannel.setMethodCallHandler(this@BackupWorker)
engine.dartExecutor.executeDartCallback(
DartExecutor.DartCallback(
applicationContext.assets,
appBundlePath,
callbackInformation
)
)
}
}
override fun onStopped() {
Log.d(TAG, "onStopped")
// called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
backgroundChannel.invokeMethod("systemStop", null)
}
// cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException)
// instead, wait for 5 seconds until forcefully stopping backup work
Handler(Looper.getMainLooper()).postDelayed({
stopEngine(null)
}, 5000)
}
private fun stopEngine(result: Result?) {
if (result != null) {
Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(result)
}
engine?.destroy()
engine = null
clearBackgroundNotification()
}
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
when (call.method) {
"initialized" -> {
timeBackupStarted = SystemClock.uptimeMillis()
backgroundChannel.invokeMethod(
"onAssetsChanged",
null,
object : MethodChannel.Result {
override fun notImplemented() {
stopEngine(Result.failure())
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
stopEngine(Result.failure())
}
override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean
stopEngine(if(success) Result.success() else Result.retry())
}
}
)
}
"updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String
val content = args.get(1) as String
if (isIgnoringBatteryOptimizations) {
setForegroundAsync(createForegroundInfo(title, content))
} else {
showBackgroundInfo(title, content)
}
}
"showError" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String
val content = args.get(1) as String
val individualTag = args.get(2) as String?
showError(title, content, individualTag)
}
"clearErrorNotifications" -> clearErrorNotifications()
"hasContentChanged" -> {
val lastChange = applicationContext
.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted)
val hasContentChanged = lastChange > timeBackupStarted;
timeBackupStarted = SystemClock.uptimeMillis()
r.success(hasContentChanged)
}
else -> r.notImplemented()
}
}
private fun showError(title: String, content: String, individualTag: String?) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.build()
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
}
private fun clearErrorNotifications() {
notificationManager.cancel(NOTIFICATION_ERROR_ID)
}
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_ID)
}
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.build()
return ForegroundInfo(NOTIFICATION_ID, notification)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createChannel() {
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(foreground)
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(error)
}
companion object {
const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2
private const val ONE_MINUTE = 60000L
/**
* Enqueues the BackupWorker to run once the constraints are met
*/
fun enqueueBackupWorker(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L) {
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
}
/**
* Updates the constraints of an already enqueued BackupWorker
*/
fun updateBackupWorker(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
try {
val wm = WorkManager.getInstance(context)
val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP)
val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS)
if (workInfoList != null) {
for (workInfo in workInfoList) {
if (workInfo.getState() == WorkInfo.State.ENQUEUED) {
val workRequest = buildWorkRequest(requireWifi, requireCharging)
wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest)
Log.d(TAG, "updateBackupWorker updated BackupWorker constraints")
return
}
}
}
Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued")
} catch (e: Exception) {
Log.d(TAG, "updateBackupWorker failed: ${e}")
}
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun stopWork(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
Log.d(TAG, "stopWork: BackupWorker cancelled")
}
/**
* Returns `true` if the app is ignoring battery optimizations
*/
fun isIgnoringBatteryOptimizations(ctx: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pwrm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
val name = ctx.packageName
return pwrm.isIgnoringBatteryOptimizations(name)
}
return true
}
private fun buildWorkRequest(requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging)
.build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
.setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS)
.build()
return work
}
private val flutterLoader = FlutterLoader()
}
}
private const val TAG = "BackupWorker"

View File

@@ -0,0 +1,137 @@
package app.alextran.immich
import android.content.Context
import android.os.SystemClock
import android.provider.MediaStore
import android.util.Log
import androidx.work.Constraints
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Operation
import java.util.concurrent.TimeUnit
/**
* Worker executed by Android WorkManager observing content changes (new photos/videos)
*
* Immediately enqueues the BackupWorker when running.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again after each run.
*/
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result {
if (!isEnabled(applicationContext)) {
return Result.failure()
}
if (getTriggeredContentUris().size > 0) {
startBackupWorker(applicationContext, delayMilliseconds = 0)
}
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
return Result.success()
}
companion object {
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
/**
* Enqueues the `ContentObserverWorker`.
*
* @param context Android Context
*/
fun enable(context: Context, immediate: Boolean = false) {
// migration to remove any old active background task
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
Log.d(TAG, "enabled ContentObserverWorker")
if (immediate) {
startBackupWorker(context, delayMilliseconds = 5000)
}
}
/**
* Configures the `BackupWorker` to run when all constraints are met.
*
* @param context Android Context
* @param requireWifi if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
*/
fun configureWork(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
.apply()
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun disable(context: Context) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
Log.d(TAG, "disabled ContentObserverWorker")
}
/**
* Return true if the user has enabled the background backup service
*/
fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
}
/**
* Enqueue and replace the worker without the content trigger but with a short delay
*/
fun workManagerAppClearedWorkaround(context: Context) {
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setInitialDelay(500, TimeUnit.MILLISECONDS)
.build()
WorkManager
.getInstance(context)
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
.getResult()
.get()
Log.d(TAG, "workManagerAppClearedWorkaround")
}
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(5000, TimeUnit.MILLISECONDS)
.build()
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
}
private fun startBackupWorker(context: Context, delayMilliseconds: Long) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
}
}
}
private const val TAG = "ContentObserverWorker"

View File

@@ -1,6 +1,25 @@
package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.getPlugins().add(BackgroundServicePlugin())
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
startService(Intent(getBaseContext(), AppClearedService::class.java));
} catch (e: Exception) {
// startService must not be called when app is in background (crashes app)
// there is nothing we can do
}
}
}

View File

@@ -1,5 +1,8 @@
buildscript {
ext.kotlin_version = '1.6.10'
ext.work_version = '2.7.1'
ext.concurrent_version = '1.1.0'
ext.guava_version = '31.0.1-android'
repositories {
google()
mavenCentral()

View File

@@ -30,8 +30,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 31,
"android.injected.version.name" => "1.21.0",
"android.injected.version.code" => 44,
"android.injected.version.name" => "1.29.4",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1 @@
* Modify Album API endpoint to return count attribute instead of all assets to reduce network consumption and CPU processing.

View File

@@ -0,0 +1,2 @@
* Added setting screen
* Implemented dark mode

View File

@@ -0,0 +1,3 @@
* Feature - [Android] Background backup.
* Fixed - [iOS] Dark mode not auto switch.
* Fixed - WebSocket not getting correct data on mobile.

View File

@@ -0,0 +1,4 @@
* Feature - Customization options for asset grid
* Added pt-BR Translation: Translation into Portuguese Brazil
* Feature - Show notifications on background backup errors
* Optimization - Use CachedNetworkImage and separate cache for thumbnails on library page

View File

@@ -0,0 +1 @@
* Fixed rendering blank when failed to parse datetime on main timeline

View File

@@ -0,0 +1,2 @@
* Add cache setting and improve caching mechanism
* Persist WiFi + charging settings of background backup

View File

@@ -0,0 +1,2 @@
* Fixed remove empty translations
* Fixed search page crashes the app on some Android models

View File

@@ -0,0 +1 @@
* Improve Android background service reliability

View File

@@ -0,0 +1 @@
* Fix background service cannot run in release build

View File

@@ -0,0 +1,2 @@
* Fixed oversize play button on video
* Fixed app crashing when swipe between assets

View File

@@ -0,0 +1,2 @@
* Fixed Android BackgroundServiceStartNotAllowedException
* Restore old cache mechanism

View File

@@ -0,0 +1 @@
* Update deprecated API that causes notification not dismissing after background upload progress finished.

View File

@@ -0,0 +1 @@
* Fixed app crashes when there is no object detection result.

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000221">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00043">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.750133">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="71.114285">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="35.558064">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.949677">
</testcase>

View File

@@ -1,6 +1,9 @@
{
"album_info_card_backup_album_excluded": "AUSGESCHLOSSEN",
"album_info_card_backup_album_included": "EINGESCHLOSSEN",
"album_thumbnail_card_item": "1 Element",
"album_thumbnail_card_items": "{} Elemente",
"album_thumbnail_card_shared": " · Geteilt",
"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",
@@ -46,8 +49,10 @@
"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",
"control_bottom_app_bar_share": "Teilen",
"create_album_page_untitled": "Unbenannt",
"create_shared_album_page_create": "Erstellen",
"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",
@@ -60,6 +65,8 @@
"exif_bottom_sheet_description": "Beschreibung hinzufügen...",
"exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "STANDORT",
"library_page_albums": "Alben",
"library_page_new_album": "Neues Album",
"login_form_button_text": "Anmelden",
"login_form_email_hint": "deine@email.de",
"login_form_endpoint_hint": "http://deine-server-ip:port/api",
@@ -68,13 +75,14 @@
"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": "Fehler bei der Anmeldung, überprüfen Sie Server URL, E-Mail und Passwort",
"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": "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_settings": "Einstellungen",
"profile_drawer_sign_out": "Abmelden",
"search_bar_hint": "Durchsuche deine Fotos",
"search_page_no_objects": "Keine Objektinformationen verfügbar",
@@ -84,33 +92,35 @@
"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": "Vorschläge",
"select_user_for_sharing_page_share_suggestions": "Suggestions",
"setting_pages_app_bar_settings": "Einstellungen",
"share_add": "Hinzufügen",
"share_add_photos": "Fotos hinzufügen",
"share_add_title": "Titel hinzufügen",
"share_create_album": "Album erstellen",
"share_dialog_preparing": "Vorbereiten...",
"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_library": "Bibliothek",
"tab_controller_nav_photos": "Fotos",
"tab_controller_nav_search": "Suche",
"tab_controller_nav_sharing": "Teilen",
"tab_controller_nav_library": "Bibliothek",
"theme_setting_dark_mode_switch": "Dunkler Modus",
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
"theme_setting_system_theme_switch": "Automatisch (Systemeinstellung folgen)",
"theme_setting_theme_subtitle": "Wählen Sie die Themeneinstellung der App",
"theme_setting_theme_title": "Theme",
"theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren kann die Performance beim Laden verbessern, erhöht allerdings den Datenverbrauch deutlich",
"theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren",
"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",
"album_thumbnail_card_item": "1 Element",
"album_thumbnail_card_items": "{} Elemente",
"album_thumbnail_card_shared": " · Geteilt",
"library_page_albums": "Alben",
"library_page_new_album": "Neues Album",
"create_album_page_untitled": "Unbenannt",
"share_dialog_preparing": "Vorbereiten...",
"control_bottom_app_bar_share": "Teilen"
}
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89"
}

View File

@@ -1,6 +1,9 @@
{
"album_info_card_backup_album_excluded": "EXCLUDED",
"album_info_card_backup_album_included": "INCLUDED",
"album_thumbnail_card_item": "1 item",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Shared",
"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",
@@ -9,6 +12,8 @@
"album_viewer_appbar_share_leave": "Leave album",
"album_viewer_appbar_share_remove": "Remove from album",
"album_viewer_page_share_add_users": "Add users",
"asset_list_settings_subtitle": "Photo grid layout settings",
"asset_list_settings_title": "Photo Grid",
"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.",
@@ -16,7 +21,26 @@
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
"backup_all": "All",
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
"backup_background_service_current_upload_notification": "Uploading {}",
"backup_background_service_default_notification": "Checking for new assets…",
"backup_background_service_error_title": "Backup error",
"backup_background_service_in_progress_notification": "Backing up your assets…",
"backup_background_service_upload_failure_notification": "Failed to upload {}",
"backup_controller_page_albums": "Backup Albums",
"backup_controller_page_background_battery_info_link": "Show me how",
"backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.",
"backup_controller_page_background_battery_info_ok": "OK",
"backup_controller_page_background_battery_info_title": "Battery optimizations",
"backup_controller_page_background_charging": "Only while charging",
"backup_controller_page_background_configure_error": "Failed to configure the background service",
"backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app",
"backup_controller_page_background_is_off": "Automatic background backup is off",
"backup_controller_page_background_is_on": "Automatic background backup is on",
"backup_controller_page_background_turn_off": "Turn off background service",
"backup_controller_page_background_turn_on": "Turn on background service",
"backup_controller_page_background_wifi": "Only on WiFi",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Selected: ",
"backup_controller_page_backup_sub": "Backed up photos and videos",
@@ -45,9 +69,24 @@
"backup_controller_page_uploading_file_info": "Uploading file info",
"backup_err_only_album": "Cannot remove the only album",
"backup_info_card_assets": "assets",
"cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
"cache_settings_clear_cache_button": "Clear cache",
"cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.",
"cache_settings_image_cache_size": "Image cache size ({} assets)",
"cache_settings_statistics_album": "Library thumbnails",
"cache_settings_statistics_assets": "{} assets ({})",
"cache_settings_statistics_full": "Full images",
"cache_settings_statistics_shared": "Shared album thumbnails",
"cache_settings_statistics_thumbnail": "Thumbnails",
"cache_settings_statistics_title": "Cache usage",
"cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application",
"cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)",
"cache_settings_title": "Caching Settings",
"control_bottom_app_bar_delete": "Delete",
"create_shared_album_page_share": "Share",
"control_bottom_app_bar_share": "Share",
"create_album_page_untitled": "Untitled",
"create_shared_album_page_create": "Create",
"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",
@@ -60,6 +99,8 @@
"exif_bottom_sheet_description": "Add Description...",
"exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "LOCATION",
"library_page_albums": "Albums",
"library_page_new_album": "New album",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
@@ -75,6 +116,7 @@
"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_settings": "Settings",
"profile_drawer_sign_out": "Sign Out",
"search_bar_hint": "Search your photos",
"search_page_no_objects": "No Objects Info Available",
@@ -85,32 +127,43 @@
"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",
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
"setting_notifications_notify_hours": "{} hours",
"setting_notifications_notify_immediately": "immediately",
"setting_notifications_notify_minutes": "{} minutes",
"setting_notifications_notify_never": "never",
"setting_notifications_subtitle": "Adjust your notification preferences",
"setting_notifications_title": "Notifications",
"setting_pages_app_bar_settings": "Settings",
"share_add": "Add",
"share_add_photos": "Add photos",
"share_add_title": "Add a title",
"share_create_album": "Create album",
"share_dialog_preparing": "Preparing...",
"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_library": "Library",
"tab_controller_nav_photos": "Photos",
"tab_controller_nav_search": "Search",
"tab_controller_nav_sharing": "Sharing",
"tab_controller_nav_library": "Library",
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
"theme_setting_dark_mode_switch": "Dark mode",
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
"theme_setting_image_viewer_quality_title": "Image viewer quality",
"theme_setting_system_theme_switch": "Automatic (Follow system setting)",
"theme_setting_theme_subtitle": "Choose the app's theme setting",
"theme_setting_theme_title": "Theme",
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
"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",
"album_thumbnail_card_item": "1 item",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Shared",
"library_page_albums": "Albums",
"library_page_new_album": "New album",
"create_album_page_untitled": "Untitled",
"share_dialog_preparing": "Preparing...",
"control_bottom_app_bar_share": "Share"
}
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
}

View File

@@ -21,12 +21,8 @@
"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",
@@ -42,7 +38,6 @@
"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",
@@ -67,7 +62,6 @@
"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",
@@ -76,14 +70,12 @@
"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",

View File

@@ -49,9 +49,6 @@
"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",
@@ -72,7 +69,6 @@
"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",

View File

@@ -1,6 +1,9 @@
{
"album_info_card_backup_album_excluded": "EXCLU",
"album_info_card_backup_album_included": "INCLUS",
"album_thumbnail_card_item": "1 élément",
"album_thumbnail_card_items": "{} éléments",
"album_thumbnail_card_shared": " · Partagé",
"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",
@@ -46,6 +49,9 @@
"backup_err_only_album": "Impossible de retirer le seul album",
"backup_info_card_assets": "éléments",
"control_bottom_app_bar_delete": "Supprimer",
"control_bottom_app_bar_share": "Partager",
"create_album_page_untitled": "Sans titre",
"create_shared_album_page_create": "Créer",
"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",
@@ -59,6 +65,8 @@
"exif_bottom_sheet_description": "Ajouter une description...",
"exif_bottom_sheet_details": "DÉTAILS",
"exif_bottom_sheet_location": "LOCALISATION",
"library_page_albums": "Albums",
"library_page_new_album": "Nouvel album",
"login_form_button_text": "Connexion",
"login_form_email_hint": "votreemail@email.com",
"login_form_endpoint_hint": "http://adresse-ip-serveur:port/api",
@@ -74,6 +82,7 @@
"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_settings": "Paramètres",
"profile_drawer_sign_out": "Se déconnecter",
"search_bar_hint": "Rechercher vos photos",
"search_page_no_objects": "Aucune information disponible sur les objets",
@@ -88,12 +97,14 @@
"share_add_photos": "Ajouter des photos",
"share_add_title": "Ajouter un titre",
"share_create_album": "Créer un album",
"share_dialog_preparing": "Préparation...",
"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_library": "Bibliothèque",
"tab_controller_nav_photos": "Photos",
"tab_controller_nav_search": "Recherche",
"tab_controller_nav_sharing": "Partage",

View File

@@ -1,28 +1,52 @@
{
"album_info_card_backup_album_excluded": "ESCLUSI",
"album_info_card_backup_album_included": "INCLUSI",
"album_thumbnail_card_item": "1 elemento ",
"album_thumbnail_card_items": "{} elementi",
"album_thumbnail_card_shared": "Condiviso",
"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_delete": "Errore nel cancellare l'album ",
"album_viewer_appbar_share_err_leave": "Errore 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_err_title": "Errore nel cambiare il titolo dell'album ",
"album_viewer_appbar_share_leave": "Lascia 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 ({})",
"asset_list_settings_subtitle": "Impostazion del layout della griglia delle foto",
"asset_list_settings_title": "Griglia foto",
"backup_album_selection_page_albums_device": "Albums sul 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_background_service_backup_failed_message": "Impossibile caricare contenuti. Nuovo tentativo…",
"backup_background_service_connection_failed_message": "Impossibile connettersi al server. Nuovo tentativo…",
"backup_background_service_current_upload_notification": "Caricamento {}",
"backup_background_service_default_notification": "Verifica di nuovi contenuti…",
"backup_background_service_error_title": "Errore di Backup",
"backup_background_service_in_progress_notification": "Backing dei tuoi contenuti…",
"backup_background_service_upload_failure_notification": "Impossibile caricare {}",
"backup_controller_page_albums": "Backup Album",
"backup_controller_page_background_battery_info_link": "Mostrami come",
"backup_controller_page_background_battery_info_message": "Per una migliore esperienza di backup, disabilita le ottimisazioni della batteria per l'app Immich.\n\nDal momento che è una funzionalità specifica del dispositivo, per favore consulta il manuale del produttore.",
"backup_controller_page_background_battery_info_ok": "OK",
"backup_controller_page_background_battery_info_title": "Ottimizzazioni batteria",
"backup_controller_page_background_charging": "Solo durante la ricarica",
"backup_controller_page_background_configure_error": "Impossibile configurare i servizi in background",
"backup_controller_page_background_description": "Abilita i servizi in background per sincronizzare tutti i nuovi contenuti senza la necessità di aprire l'app",
"backup_controller_page_background_is_off": "Backup automatico spento",
"backup_controller_page_background_is_on": "Backup automatico attivo",
"backup_controller_page_background_turn_off": "Disabilita servizi in background",
"backup_controller_page_background_turn_on": "Abilita servizi in background",
"backup_controller_page_background_wifi": "Solo su WiFi",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Selezionati:",
"backup_controller_page_backup_sub": "Photo e video salvati",
"backup_controller_page_backup_sub": "Foto e video caricati",
"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_desc_backup": "Attiva il backup per eseguire il caricamento automatico sul server",
"backup_controller_page_excluded": "Esclusi:",
"backup_controller_page_failed": "Falliti: ({})",
"backup_controller_page_filename": "Nome del file: {} [{}]",
@@ -30,39 +54,57 @@
"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_remainder_sub": "Photo e album selezionati che rimangono da caricare",
"backup_controller_page_select": "Seleziona ",
"backup_controller_page_server_storage": "Spazio nel server",
"backup_controller_page_server_storage": "Spazio sul 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_to_backup": "Album da caricare",
"backup_controller_page_total": "Totale",
"backup_controller_page_total_sub": "Tutte le foto e i video unici salvati dagli album selezionati ",
"backup_controller_page_total_sub": "Tutte le foto e i video unici caricati 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 ",
"backup_info_card_assets": "oggetti ",
"cache_settings_album_thumbnails": "Anteprime pagine librerie ({} assets)",
"cache_settings_clear_cache_button": "Cancella cache",
"cache_settings_clear_cache_button_title": "Cancella cache app. Questo impatterà sulle prestazioni applicative fino a quando la cache non sarà rigenerata.",
"cache_settings_image_cache_size": "Dimensione cache foto ({} assets)",
"cache_settings_statistics_album": "Anteprime librerie",
"cache_settings_statistics_assets": "{} contenuti ({})",
"cache_settings_statistics_full": "Immagini complete",
"cache_settings_statistics_shared": "Anteprime album condivisi",
"cache_settings_statistics_thumbnail": "Anteprime",
"cache_settings_statistics_title": "Uso della cache",
"cache_settings_subtitle": "Controlla il comportamento della cache",
"cache_settings_thumbnail_size": "Dimensione cache anteprime ({} assets)",
"cache_settings_title": "Impostazioni della Cache",
"control_bottom_app_bar_delete": "Elimina",
"control_bottom_app_bar_share": "Condividi",
"create_album_page_untitled": "Senza titolo",
"create_shared_album_page_create": "Crea",
"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_alert": "Questi oggetti saranno cancellati definitivamente da Immich e dal tuo device",
"delete_dialog_cancel": "Annulla",
"delete_dialog_ok": "Elimina",
"delete_dialog_title": "Cancella in modo permanente ",
"delete_dialog_title": "Cancella definitivamente",
"exif_bottom_sheet_description": "Aggiungi una descrizione...",
"exif_bottom_sheet_details": "DETTAGLI",
"exif_bottom_sheet_location": "POSIZIONE",
"login_form_button_text": "Accedi",
"library_page_albums": "Album",
"library_page_new_album": "Nuovo Album",
"login_form_button_text": "Login",
"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_endpoint_url": "Server Endpoint URL",
"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 ",
@@ -74,33 +116,54 @@
"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",
"profile_drawer_settings": "Impostazioni ",
"profile_drawer_sign_out": "Logout",
"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_no_objects": "Nessuna informazione relativa all'oggetto disponibile",
"search_page_no_places": "Nessun informazione sul luogo disponibile",
"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_err_album": "Errore nel creare l'album ",
"select_user_for_sharing_page_share_suggestions": "Suggerimenti",
"setting_notifications_notify_failures_grace_period": "Notifica caricamenti falliti in background: {}",
"setting_notifications_notify_hours": "{} Ore",
"setting_notifications_notify_immediately": "Immediatamente",
"setting_notifications_notify_minutes": "{} Minuti",
"setting_notifications_notify_never": "Mai",
"setting_notifications_subtitle": "Cambia le impostazioni di notifica",
"setting_notifications_title": "Notifiche",
"setting_pages_app_bar_settings": "Impostazioni",
"share_add": "Aggiungi",
"share_add_photos": "Aggiungi foto",
"share_add_title": "Aggiungi un titolo ",
"share_create_album": "Crea album",
"share_invite": "Invitare all'album ",
"share_dialog_preparing": "Preparo…",
"share_invite": "Invitare nell'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_description": "Crea un album condiviso per condividere foto e video con persone 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_library": "Libreria",
"tab_controller_nav_photos": "Foto",
"tab_controller_nav_search": "Cerca",
"tab_controller_nav_sharing": "Condividi",
"theme_setting_asset_list_storage_indicator_title": "Mostra indicatore dello storage nei titoli dei contenuti",
"theme_setting_asset_list_tiles_per_row_title": "Numero di contenuti per riga ({})",
"theme_setting_dark_mode_switch": "Dark mode",
"theme_setting_image_viewer_quality_subtitle": "Cambia la qualità del dettaglio dell'immagine",
"theme_setting_image_viewer_quality_title": "Qualità immagine",
"theme_setting_system_theme_switch": "Automatico (Vai alle impostazioni di sistema)",
"theme_setting_theme_subtitle": "Scegli un'impostazione per il tema",
"theme_setting_theme_title": "Tema",
"theme_setting_three_stage_loading_subtitle": "Il caricamento in 3 stage aumenterà le performance di caricamento ma anche il consumo di banda",
"theme_setting_three_stage_loading_title": "Abilita il caricamento a tre stage",
"version_announcement_overlay_ack": "Riconosci ",
"version_announcement_overlay_release_notes": "le note di rilascio ",
"version_announcement_overlay_release_notes": "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_2": "per favore prenditi il tuo tempo per controllare il",
"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"
"version_announcement_overlay_title": "Nuova versione del server disponibile! \uD83C\uDF89"
}

View File

@@ -0,0 +1,152 @@
{
"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_background_service_default_notification": "새 미디어파일 확인중...",
"backup_background_service_upload_failure_notification": "{} 업로드 실패",
"backup_background_service_in_progress_notification": "미디어파일 백업 중...",
"backup_background_service_current_upload_notification": "{} 업로드 중",
"backup_background_service_error_title": "백업 오류",
"backup_background_service_connection_failed_message": "서버에 연결하지 못했습니다. 다시 시도하는 중...",
"backup_background_service_backup_failed_message": "미디어파일을 백업하지 못했습니다. 다시 시도하는 중...",
"backup_controller_page_albums": "백업대상",
"backup_controller_page_backup": "백업",
"backup_controller_page_backup_selected": "선택됨: ",
"backup_controller_page_backup_sub": "백업된 사진 및 비디오",
"backup_controller_page_background_description": "백그라운드 서비스를 켜서 앱을 열지 않고도 새 미디어파일을 자동으로 백업합니다.",
"backup_controller_page_background_wifi": "WiFi에서만",
"backup_controller_page_background_charging": "충전 중일 때만",
"backup_controller_page_background_is_on": "자동 백그라운드 백업이 켜져 있습니다",
"backup_controller_page_background_is_off": "자동 백그라운드 백업이 꺼져 있습니다",
"backup_controller_page_background_turn_on": "백그라운드 서비스 켜기",
"backup_controller_page_background_turn_off": "백그라운드 서비스 끄기",
"backup_controller_page_background_configure_error": "백그라운드 서비스를 구성하지 못했습니다",
"backup_controller_page_cancel": "취소",
"backup_controller_page_created": "생성일: {}",
"backup_controller_page_desc_backup": "새 미디어파일을 서버에 자동으로 업로드하려면 백업을 켜주세요.",
"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": "백업이 꺼져 있습니다",
"backup_controller_page_status_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": "백업 끄기",
"backup_controller_page_turn_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_create": "만들기",
"create_shared_album_page_share_add_assets": "사진 추가",
"create_shared_album_page_share_select_photos": "사진 선택",
"daily_title_text_date": "E, M월 d일",
"daily_title_text_date_year": "E, M월 d일, yyyy",
"date_format": "yyyy년 M월 d일, EEEE • a h:mm",
"delete_dialog_alert": "이 항목은 Immich 및 휴대폰에서 영구적으로 삭제됩니다",
"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": "youremail@email.com",
"login_form_endpoint_hint": "https://your-server-ip: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": "y년 M월",
"profile_drawer_client_server_up_to_date": "클라이언트와 서버가 최신 상태입니다",
"profile_drawer_sign_out": "로그아웃",
"profile_drawer_settings": "설정",
"search_bar_hint": "사진 검색",
"search_page_no_objects": "발견된 사물이\n없습니다",
"search_page_no_places": "발견된 장소가\n없습니다",
"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": "공유앨범 없음",
"sharing_silver_appbar_create_shared_album": "공유앨범 만들기",
"sharing_silver_appbar_share_partner": "파트너와 공유",
"tab_controller_nav_photos": "사진",
"tab_controller_nav_search": "검색",
"tab_controller_nav_sharing": "공유",
"tab_controller_nav_library": "라이브러리",
"version_announcement_overlay_ack": "승인",
"version_announcement_overlay_release_notes": "릴리스 정보",
"version_announcement_overlay_text_1": "안녕하세요!",
"version_announcement_overlay_text_2": "앱에 새로운 업데이트가 있습니다!",
"version_announcement_overlay_text_3": "특히 WatchTower 또는 서버 응용 프로그램 자동 업데이트를 처리하는 메커니즘을 사용하는 경우 잘못된 구성을 방지하기 위해 docker-compose 및 .env 설정이 최신 상태인지 확인하세요.",
"version_announcement_overlay_title": "새 서버 버전 사용 가능 \uD83C\uDF89",
"album_thumbnail_card_item": "1개 항목",
"album_thumbnail_card_items": "{}개 항목",
"album_thumbnail_card_shared": " · 공유",
"library_page_albums": "앨범",
"library_page_new_album": "새 앨범",
"create_album_page_untitled": "제목없음",
"share_dialog_preparing": "준비중...",
"control_bottom_app_bar_share": "공유",
"setting_pages_app_bar_settings": "설정",
"theme_setting_theme_title": "테마",
"theme_setting_theme_subtitle": "앱테마 선택",
"theme_setting_system_theme_switch": "자동(시스템 설정에 따름)",
"theme_setting_dark_mode_switch": "다크모드",
"theme_setting_image_viewer_quality_title": "이미지 뷰어 품질",
"theme_setting_image_viewer_quality_subtitle": "디테일 이미지 뷰어 품질 조정",
"theme_setting_three_stage_loading_title": "3단계 로딩 활성화",
"theme_setting_three_stage_loading_subtitle": "이 기능은 로딩 성능을 향상시킬 수 있지만 훨씬 더 많은 데이터를 사용합니다.",
"asset_list_settings_title": "사진 배열",
"asset_list_settings_subtitle": "사진 배열 레이아웃 설정",
"theme_setting_asset_list_storage_indicator_title": "미디어 타일에 스토리지 싱크여부 표시",
"theme_setting_asset_list_tiles_per_row_title": "한 줄에 표시할 미디어 수 ({})",
"setting_notifications_title": "알림",
"setting_notifications_subtitle": "알림 기본 설정 조정",
"setting_notifications_notify_failures_grace_period": "백그라운드 백업 실패 알림: {}",
"setting_notifications_notify_immediately": "즉시",
"setting_notifications_notify_minutes": "{}분 뒤",
"setting_notifications_notify_hours": "{}시간 뒤",
"setting_notifications_notify_never": "알리지 않음"
}

View File

@@ -0,0 +1,152 @@
{
"album_info_card_backup_album_excluded": "UITGESLOTEN",
"album_info_card_backup_album_included": "INGESLOTEN",
"album_viewer_appbar_share_delete": "Verwijder album",
"album_viewer_appbar_share_err_delete": "Fout bij verwijderen album",
"album_viewer_appbar_share_err_leave": "Fout bij verlaten album",
"album_viewer_appbar_share_err_remove": "Er gaat iets mis bij het verwijderen van items uit het album",
"album_viewer_appbar_share_err_title": "Fout bij wijzigen album titel",
"album_viewer_appbar_share_leave": "Verlaat album",
"album_viewer_appbar_share_remove": "Verwijder uit album",
"album_viewer_page_share_add_users": "Voeg gebruiker toe",
"backup_album_selection_page_albums_device": "Albums op apparaat ({})",
"backup_album_selection_page_albums_tap": "Tik om in te voegen, dubbel tik om uit te sluiten",
"backup_album_selection_page_assets_scatter": "Items kunnen over verschillende albums verdeeld zijn, dus albums kunnen ingesloten of uitgesloten zijn van het backup proces.",
"backup_album_selection_page_select_albums": "Selecteer albums",
"backup_album_selection_page_selection_info": "Selectie info",
"backup_album_selection_page_total_assets": "Totaal unieke items",
"backup_all": "Alle",
"backup_background_service_default_notification": "Controleren op nieuw items…",
"backup_background_service_upload_failure_notification": "Fout bij upload {}",
"backup_background_service_in_progress_notification": "Backuppen van items…",
"backup_background_service_current_upload_notification": "Uploaden {}",
"backup_background_service_error_title": "Backup fout",
"backup_background_service_connection_failed_message": "Fout bij verbinden server. Opnieuw proberen…",
"backup_background_service_backup_failed_message": "Fout bij backuppen items. Opnieuw proberen…",
"backup_controller_page_albums": "Backup Albums",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Geselecteerd: ",
"backup_controller_page_backup_sub": "Foto's en video's gebackupped",
"backup_controller_page_background_description": "Gebruik achtergrondservice om automatisch nieuwe items te uploaden naar server zonder de app te openen",
"backup_controller_page_background_wifi": "Alleen op WiFi",
"backup_controller_page_background_charging": "Alleen tijdens opladen",
"backup_controller_page_background_is_on": "Automatische achtergrond backup staat aan",
"backup_controller_page_background_is_off": "Automatische achtergrond backup staat uit",
"backup_controller_page_background_turn_on": "Zet achtergrondservice aan",
"backup_controller_page_background_turn_off": "Zet achtergrondservice uit",
"backup_controller_page_background_configure_error": "Achtergrondservice configuratie mislukt",
"backup_controller_page_cancel": "Annuleren",
"backup_controller_page_created": "Gemaakt op: {}",
"backup_controller_page_desc_backup": "Configureer backup om automatisch nieuwe items te uploaden naar server.",
"backup_controller_page_excluded": "Uitgezonderd: ",
"backup_controller_page_failed": "Mislukt ({})",
"backup_controller_page_filename": "Bestandsnaam: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Backup informatie",
"backup_controller_page_none_selected": "Geen geselecteerd",
"backup_controller_page_remainder": "Rest",
"backup_controller_page_remainder_sub": "Overgebleven foto's en video's om te backuppen uit selectie",
"backup_controller_page_select": "Selecteer",
"backup_controller_page_server_storage": "Server Opslag",
"backup_controller_page_start_backup": "Start Backup",
"backup_controller_page_status_off": "Backup staat uit",
"backup_controller_page_status_on": "Backup staat aan",
"backup_controller_page_storage_format": "{} van {} gebruikt",
"backup_controller_page_to_backup": "Albums om te backuppen",
"backup_controller_page_total": "Totaal",
"backup_controller_page_total_sub": "Alle unieke foto's en video's uit geselecteerde albums",
"backup_controller_page_turn_off": "Backup uitzetten",
"backup_controller_page_turn_on": "Backup aanzetten",
"backup_controller_page_uploading_file_info": "Bestandsgegevens uploaden",
"backup_err_only_album": "Kan niet alleen het album verwijderen",
"backup_info_card_assets": "items",
"control_bottom_app_bar_delete": "Verwijderen",
"create_shared_album_page_share": "Delen",
"create_shared_album_page_create": "Aanmaken",
"create_shared_album_page_share_add_assets": "VOEG FOTO'S TOE",
"create_shared_album_page_share_select_photos": "Selecteer Foto's",
"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": "Deze items zullen permanent verwijderd worden van Immich en je apparaat",
"delete_dialog_cancel": "Annuleren",
"delete_dialog_ok": "Verwijderen",
"delete_dialog_title": "Verwijder permanent",
"exif_bottom_sheet_description": "Voeg beschrijving toe...",
"exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "LOCATIE",
"login_form_button_text": "Login",
"login_form_email_hint": "jouwemail@email.com",
"login_form_endpoint_hint": "http://jouw-server-ip:port/api",
"login_form_endpoint_url": "Server URL",
"login_form_err_http": "Voer http:// of https:// in",
"login_form_err_invalid_email": "Ongeldige Email",
"login_form_err_leading_whitespace": "Spatie aan het begin",
"login_form_err_trailing_whitespace": "Spatie aan het eind",
"login_form_failed_login": "Fout bij inloggen, controleer server url, email en wachtwoord",
"login_form_label_email": "Email",
"login_form_label_password": "Wachtwoord",
"login_form_password_hint": "wachtwoord",
"login_form_save_login": "Ingelogd blijven",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Client en Server zijn up-to-date",
"profile_drawer_sign_out": "Uitloggen",
"profile_drawer_settings": "Instellingen",
"search_bar_hint": "Zoek je foto's",
"search_page_no_objects": "Geen object gegevens beschikbaar",
"search_page_no_places": "Geen locatie gegevens beschikbaar",
"search_page_places": "Plaatsen",
"search_page_things": "Dingen",
"search_result_page_new_search_hint": "Nieuw resultaat",
"select_additional_user_for_sharing_page_suggestions": "Suggesties",
"select_user_for_sharing_page_err_album": "Album aanmaken mislukt",
"select_user_for_sharing_page_share_suggestions": "Suggesties",
"share_add": "Toevoegen",
"share_add_photos": "Foto's toevoegen",
"share_add_title": "Titel toevoegen",
"share_create_album": "Album aanmaken",
"share_invite": "Uitnodigen voor album",
"sharing_page_album": "Gedeelde albums",
"sharing_page_description": "Maak gedeelde albums om foto's en video's te delen met mensen in je netwerk.",
"sharing_page_empty_list": "LEGE LIJST",
"sharing_silver_appbar_create_shared_album": "Maak gedeeld album",
"sharing_silver_appbar_share_partner": "Delen met partner",
"tab_controller_nav_photos": "Foto's",
"tab_controller_nav_search": "Zoeken",
"tab_controller_nav_sharing": "Delen",
"tab_controller_nav_library": "Bibliotheek",
"version_announcement_overlay_ack": "Bevestig",
"version_announcement_overlay_release_notes": "release opmerkingen",
"version_announcement_overlay_text_1": "Er is een nieuwe versie beschikbaar van",
"version_announcement_overlay_text_2": "neem je tijd en bezoek de ",
"version_announcement_overlay_text_3": " controleer of je docker-compose en .env up-to-date zijn om te voorkomen dat er misconfiguraties zijn, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je server automatisch configureert.",
"version_announcement_overlay_title": "Nieuwe server versie beschikbaar \uD83C\uDF89",
"album_thumbnail_card_item": "1 item",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Gedeeld",
"library_page_albums": "Albums",
"library_page_new_album": "Nieuw album",
"create_album_page_untitled": "Naamloos",
"share_dialog_preparing": "Voorbereiden...",
"control_bottom_app_bar_share": "Delen",
"setting_pages_app_bar_settings": "Instellingen",
"theme_setting_theme_title": "Thema",
"theme_setting_theme_subtitle": "Kies de thema instelling van de app",
"theme_setting_system_theme_switch": "Automatisch (volg systeeminstelling)",
"theme_setting_dark_mode_switch": "Donkere modus",
"theme_setting_image_viewer_quality_title": "Foto weergave kwaliteit",
"theme_setting_image_viewer_quality_subtitle": "Pas de kwaliteit aan van de gedetailleerde foto weergave",
"theme_setting_three_stage_loading_title": "Drie-laags laden inschakelen",
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
"asset_list_settings_title": "Foto Grid",
"asset_list_settings_subtitle": "Foto grid layout instellingen",
"theme_setting_asset_list_storage_indicator_title": "Laat ruimte indicator zien bij item tegels",
"theme_setting_asset_list_tiles_per_row_title": "Aantal items per rij ({})",
"setting_notifications_title": "Notificaties",
"setting_notifications_subtitle": "Werk je notificatievoorkeuren bij",
"setting_notifications_notify_failures_grace_period": "Melding achtergrond backup fouten: {}",
"setting_notifications_notify_immediately": "meteen",
"setting_notifications_notify_minutes": "{} minuten",
"setting_notifications_notify_hours": "{} uur",
"setting_notifications_notify_never": "nooit"
}

View File

@@ -0,0 +1,139 @@
{
"album_info_card_backup_album_excluded": "EXCLUÍDO",
"album_info_card_backup_album_included": "INCLUÍDO",
"album_viewer_appbar_share_delete": "Excluir álbum",
"album_viewer_appbar_share_err_delete": "Falha ao excluir álbum",
"album_viewer_appbar_share_err_leave": "Falha ao sair do álbum",
"album_viewer_appbar_share_err_remove": "Há problemas ao remover recursos do álbum",
"album_viewer_appbar_share_err_title": "Falha ao alterar o título do álbum",
"album_viewer_appbar_share_leave": "Sair do álbum",
"album_viewer_appbar_share_remove": "Remover do álbum",
"album_viewer_page_share_add_users": "Adicionar usuários",
"backup_album_selection_page_albums_device": "Álbuns no dispositivo ({})",
"backup_album_selection_page_albums_tap": "Toque para incluir, toque duas vezes para excluir",
"backup_album_selection_page_assets_scatter": "Os recursos podem se espalhar por vários álbuns. Assim, os álbuns podem ser incluídos ou excluídos durante o processo de backup.",
"backup_album_selection_page_select_albums": "Selecionar álbuns",
"backup_album_selection_page_selection_info": "Informações da Seleção",
"backup_album_selection_page_total_assets": "Total de recursos exclusivos",
"backup_all": "Todos",
"backup_background_service_default_notification": "Checking for new assets…",
"backup_background_service_upload_failure_notification": "Falha ao carregar {}",
"backup_background_service_in_progress_notification": "Fazendo backup de seus ativos…",
"backup_background_service_current_upload_notification": "Enviando {}",
"backup_controller_page_albums": "Álbuns de backup",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Selecionado: ",
"backup_controller_page_backup_sub": "Backup de fotos e vídeos",
"backup_controller_page_background_description": "Ative o serviço em segundo plano para fazer backup automático de novos ativos sem precisar abrir o aplicativo",
"backup_controller_page_background_wifi": "Apenas em Wi-Fi",
"backup_controller_page_background_charging": "Apenas durante o carregamento",
"backup_controller_page_background_is_on": "O backup automático em segundo plano está ativado",
"backup_controller_page_background_is_off": "O backup automático em segundo plano está desativado",
"backup_controller_page_background_turn_on": "Ativar o serviço em segundo plano",
"backup_controller_page_background_turn_off": "Desativar o serviço em segundo plano",
"backup_controller_page_background_configure_error": "Falha ao configurar o serviço em segundo plano",
"backup_controller_page_cancel": "Cancelar",
"backup_controller_page_created": "Criado em: {}",
"backup_controller_page_desc_backup": "Ative o backup para carregar automaticamente novos ativos no servidor.",
"backup_controller_page_excluded": "Excluído: ",
"backup_controller_page_failed": "Falhou ({})",
"backup_controller_page_filename": "Nome do arquivo: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Informações de backup",
"backup_controller_page_none_selected": "Nenhum selecionado",
"backup_controller_page_remainder": "Restante",
"backup_controller_page_remainder_sub": "Fotos e vídeos restantes para fazer backup da seleção",
"backup_controller_page_select": "Selecionar",
"backup_controller_page_server_storage": "Armazenamento do servidor",
"backup_controller_page_start_backup": "Iniciar backup",
"backup_controller_page_status_off": "O backup está desativado",
"backup_controller_page_status_on": "O backup está ativado",
"backup_controller_page_storage_format": "{} de {} usado",
"backup_controller_page_to_backup": "Álbuns para backup",
"backup_controller_page_total": "Total",
"backup_controller_page_total_sub": "Todas as fotos e vídeos únicos dos álbuns selecionados",
"backup_controller_page_turn_off": "Desativar o backup",
"backup_controller_page_turn_on": "Ativar Backup",
"backup_controller_page_uploading_file_info": "Carregando informações do arquivo",
"backup_err_only_album": "Não é possível remover o único álbum",
"backup_info_card_assets": "ativos",
"control_bottom_app_bar_delete": "Excluir",
"create_shared_album_page_share": "Compartilhar",
"create_shared_album_page_create": "Criar",
"create_shared_album_page_share_add_assets": "ADICIONAR FOTOS",
"create_shared_album_page_share_select_photos": "Selecionar fotos",
"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": "Esses itens serão excluídos permanentemente do Immich e do seu dispositivo",
"delete_dialog_cancel": "Cancelar",
"delete_dialog_ok": "Excluir",
"delete_dialog_title": "Excluir permanentemente",
"exif_bottom_sheet_description": "Adicionar descrição...",
"exif_bottom_sheet_details": "DETALHES",
"exif_bottom_sheet_location": "LOCALIZAÇÃO",
"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": "E-mail inválido",
"login_form_err_leading_whitespace": "Leading whitespace",
"login_form_err_trailing_whitespace": "Trailing whitespace",
"login_form_failed_login": "Erro ao fazer login, verifique a url do servidor, e-mail e senha",
"login_form_label_email": "Email",
"login_form_label_password": "Password",
"login_form_password_hint": "password",
"login_form_save_login": "Permaneçer conectado",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Cliente e Servidor estão atualizados",
"profile_drawer_sign_out": "Sair",
"profile_drawer_settings": "Configurações",
"search_bar_hint": "Procurar fotos",
"search_page_no_objects": "Nenhuma informação de objeto disponível",
"search_page_no_places": "Nenhuma informação de lugares disponível",
"search_page_places": "Lugares",
"search_page_things": "Coisas",
"search_result_page_new_search_hint": "Nova pesquisa",
"select_additional_user_for_sharing_page_suggestions": "Sugestões",
"select_user_for_sharing_page_err_album": "Falha ao criar álbum",
"select_user_for_sharing_page_share_suggestions": "Sugestões",
"share_add": "Adicionar",
"share_add_photos": "Adicionar fotos",
"share_add_title": "Adicione um título",
"share_create_album": "Criar álbum",
"share_invite": "Convidar para o álbum",
"sharing_page_album": "Álbuns compartilhados",
"sharing_page_description": "Crie álbuns compartilhados para compartilhar fotos e vídeos com pessoas em sua rede.",
"sharing_page_empty_list": "LISTA VAZIA",
"sharing_silver_appbar_create_shared_album": "Criar álbum compartilhado",
"sharing_silver_appbar_share_partner": "Compartilhe com o parceiro",
"tab_controller_nav_photos": "Fotos",
"tab_controller_nav_search": "Procurar",
"tab_controller_nav_sharing": "Compartilhamento",
"tab_controller_nav_library": "Biblioteca",
"version_announcement_overlay_ack": "Confirmar",
"version_announcement_overlay_release_notes": "notas de lançamento",
"version_announcement_overlay_text_1": "Oi amigo, há um novo lançamento de",
"version_announcement_overlay_text_2": "reserve um tempo para visitar o ",
"version_announcement_overlay_text_3": " e verifique se a configuração do docker-compose e do .env está atualizada para evitar configurações incorretas, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização automática do aplicativo do servidor.",
"version_announcement_overlay_title": "Nova versão do servidor disponível \uD83C\uDF89",
"album_thumbnail_card_item": "1 item",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Compartilhado",
"library_page_albums": "Albums",
"library_page_new_album": "Novo album",
"create_album_page_untitled": "Sem título",
"share_dialog_preparing": "Preparando...",
"control_bottom_app_bar_share": "Compartilhar",
"setting_pages_app_bar_settings": "Configurações",
"theme_setting_theme_title": "Tema",
"theme_setting_theme_subtitle": "Escolha a configuração de tema do app",
"theme_setting_system_theme_switch": "Automático (seguir a configuração do sistema)",
"theme_setting_dark_mode_switch": "Dark mode",
"theme_setting_image_viewer_quality_title": "Qualidade das imagens do visualizador",
"theme_setting_image_viewer_quality_subtitle": "Ajuste a qualidade de imagens detalhadas do visualizador",
"theme_setting_three_stage_loading_title": "Ative o carregamento em três estágios",
"theme_setting_three_stage_loading_subtitle": "O carregamento em três estágios oferece a imagem de melhor qualidade em troca de uma velocidade de carregamento mais lenta"
}

View File

@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>9.0</string>
<string>11.0</string>
</dict>
</plist>

View File

@@ -84,7 +84,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock/ios"
SPEC CHECKSUMS:
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a

View File

@@ -360,11 +360,11 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -495,11 +495,11 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -522,11 +522,11 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.21.0</string>
<string>1.30.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>40</string>
<string>58</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
@@ -66,8 +66,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
@@ -93,7 +92,10 @@
<string>it</string>
<string>fi</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
</array>
</dict>
</plist>

View File

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

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000205">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000173">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.360401">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.412813">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.012696">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.69289">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.378836">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.408563">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="80.023705">
<testcase classname="fastlane.lanes" name="4: build_app" time="65.350555">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="98.18403">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.894733">
</testcase>

View File

@@ -16,3 +16,12 @@ const String backupInfoKey = "immichBackupAlbumInfoKey"; // Key 1
// Github Release Info
const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; // Box
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
// User Setting Info
const String userSettingInfoBox = "immichUserSettingInfoBox";
// Background backup Info
const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3

View File

@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
const immichBackgroundColor = Color(0xFFf6f8fe);
Color immichBackgroundColor = const Color(0xFFf6f8fe);
Color immichDarkBackgroundColor = const Color.fromARGB(255, 0, 0, 0);
Color immichDarkThemePrimaryColor = const Color.fromARGB(255, 173, 203, 250);

View File

@@ -0,0 +1,20 @@
import 'dart:ui';
const List<Locale> locales = [
// Default locale
Locale('en', 'US'),
// Additional locales
Locale('da', 'DK'),
Locale('de', 'DE'),
Locale('es', 'ES'),
Locale('fi', 'FI'),
Locale('fr', 'FR'),
Locale('it', 'IT'),
Locale('ja', 'JP'),
Locale('nl', 'NL'),
Locale('pl', 'PL'),
Locale('pt', 'PR'),
Locale('ko', 'KR'),
];
const String translationsPath = 'assets/i18n';

View File

@@ -8,6 +8,8 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
@@ -21,6 +23,7 @@ import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'constants/hive_box.dart';
void main() async {
@@ -33,6 +36,7 @@ void main() async {
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
await Hive.openBox(hiveGithubReleaseInfoBox);
await Hive.openBox(userSettingInfoBox);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
@@ -42,17 +46,6 @@ void main() async {
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'),
];
if (kReleaseMode && Platform.isAndroid) {
try {
await FlutterDisplayMode.setHighRefreshRate();
@@ -64,7 +57,7 @@ void main() async {
runApp(
EasyLocalization(
supportedLocales: locales,
path: 'assets/i18n',
path: translationsPath,
useFallbackTranslations: true,
fallbackLocale: locales.first,
child: const ProviderScope(child: ImmichApp()),
@@ -91,6 +84,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
if (isAuthenticated) {
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.watch(backupProvider.notifier).resumeBackup();
ref.watch(assetProvider.notifier).getAllAsset();
ref.watch(serverInfoProvider.notifier).getServerVersion();
@@ -129,8 +123,11 @@ class ImmichAppState extends ConsumerState<ImmichApp>
@override
initState() {
super.initState();
initApp().then((_) => debugPrint("App Init Completed"));
WidgetsBinding.instance.addPostFrameCallback((_) {
// needs to be delayed so that EasyLocalization is working
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
});
}
@override
@@ -154,23 +151,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primarySwatch: Colors.indigo,
fontFamily: 'WorkSans',
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
),
scaffoldBackgroundColor: immichBackgroundColor,
appBarTheme: const AppBarTheme(
backgroundColor: immichBackgroundColor,
foregroundColor: Colors.indigo,
elevation: 1,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
),
themeMode: ref.watch(immichThemeProvider),
darkTheme: immichDarkTheme,
theme: immichLightTheme,
routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate(
navigatorObservers: () => [TabNavigationObserver(ref: ref)],

View File

@@ -14,6 +14,8 @@ class AlbumActionOutlinedButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: OutlinedButton.icon(
@@ -22,19 +24,23 @@ class AlbumActionOutlinedButton extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
side: const BorderSide(
side: BorderSide(
width: 1,
color: Color.fromARGB(255, 215, 215, 215),
color: isDarkTheme
? const Color.fromARGB(255, 63, 63, 63)
: const Color.fromARGB(255, 206, 206, 206),
),
),
icon: Icon(iconData, size: 15),
icon: Icon(
iconData,
size: 15,
color: Theme.of(context).primaryColor,
),
label: Text(
labelText,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
onPressed: onPressed,
),

View File

@@ -1,14 +1,20 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:transparent_image/transparent_image.dart';
class AlbumThumbnailCard extends StatelessWidget {
const AlbumThumbnailCard({Key? key, required this.album}) : super(key: key);
const AlbumThumbnailCard({
Key? key,
required this.album,
}) : super(key: key);
final AlbumResponseDto album;
@@ -29,19 +35,18 @@ class AlbumThumbnailCard extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: FadeInImage(
child: CachedNetworkImage(
memCacheHeight: max(400, cardSize.toInt() * 3),
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
placeholder: MemoryImage(kTransparentImage),
image: NetworkImage(
'${box.get(serverEndpointKey)}/asset/thumbnail/${album.albumThumbnailAssetId}?format=JPEG',
headers: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
),
fadeInDuration: const Duration(milliseconds: 200),
fadeOutDuration: const Duration(milliseconds: 200),
imageUrl:
getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
cacheKey: "${album.albumThumbnailAssetId}",
),
),
Padding(
@@ -52,7 +57,6 @@ class AlbumThumbnailCard extends StatelessWidget {
album.albumName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
@@ -61,18 +65,18 @@ class AlbumThumbnailCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
album.assets.length == 1
album.assetCount == 1
? 'album_thumbnail_card_item'
: 'album_thumbnail_card_items',
style: const TextStyle(
fontSize: 10,
fontSize: 12,
),
).tr(args: ['${album.assets.length }']),
).tr(args: ['${album.assetCount}']),
if (album.shared)
const Text(
'album_thumbnail_card_shared',
style: TextStyle(
fontSize: 10,
fontSize: 12,
),
).tr()
],

View File

@@ -19,6 +19,8 @@ class AlbumTitleTextField extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return TextField(
onChanged: (v) {
if (v.isEmpty) {
@@ -51,7 +53,10 @@ class AlbumTitleTextField extends ConsumerWidget {
albumTitleController.clear();
isAlbumTitleEmpty.value = true;
},
icon: const Icon(Icons.cancel_rounded),
icon: Icon(
Icons.cancel_rounded,
color: Theme.of(context).primaryColor,
),
splashRadius: 10,
)
: null,
@@ -65,7 +70,9 @@ class AlbumTitleTextField extends ConsumerWidget {
),
hintText: 'share_add_title'.tr(),
focusColor: Colors.grey[300],
fillColor: Colors.grey[200],
fillColor: isDarkTheme
? const Color.fromARGB(255, 32, 33, 35)
: Colors.grey[200],
filled: isAlbumTitleTextFieldFocus.value,
),
);

View File

@@ -150,7 +150,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
void _buildBottomSheet() {
showModalBottomSheet(
backgroundColor: immichBackgroundColor,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: false,
context: context,
builder: (context) {

View File

@@ -18,6 +18,7 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final titleTextEditController =
useTextEditingController(text: albumInfo.albumName);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
void onFocusModeChange() {
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
@@ -65,7 +66,10 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
onPressed: () {
titleTextEditController.clear();
},
icon: const Icon(Icons.cancel_rounded),
icon: Icon(
Icons.cancel_rounded,
color: Theme.of(context).primaryColor,
),
splashRadius: 10,
)
: null,
@@ -78,7 +82,9 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
borderRadius: BorderRadius.circular(10),
),
focusColor: Colors.grey[300],
fillColor: Colors.grey[200],
fillColor: isDarkTheme
? const Color.fromARGB(255, 32, 33, 35)
: Colors.grey[200],
filled: titleFocusNode.hasFocus,
hintText: 'share_add_title'.tr(),
),

View File

@@ -1,7 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -14,16 +13,17 @@ import 'package:openapi/api.dart';
class AlbumViewerThumbnail extends HookConsumerWidget {
final AssetResponseDto asset;
final List<AssetResponseDto> assetList;
final bool showStorageIndicator;
const AlbumViewerThumbnail({
Key? key,
required this.asset,
required this.assetList,
this.showStorageIndicator = true,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId;
@@ -121,7 +121,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
return Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}",
cacheKey: asset.id,
width: 300,
height: 300,
memCacheHeight: 200,
@@ -166,7 +166,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
child: Stack(
children: [
_buildThumbnailImage(),
_buildAssetStoreLocationIcon(),
if (showStorageIndicator) _buildAssetStoreLocationIcon(),
if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
],

View File

@@ -1,10 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class SelectionThumbnailImage extends HookConsumerWidget {
@@ -15,10 +15,8 @@ class SelectionThumbnailImage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
var thumbnailRequestUrl = getThumbnailUrl(asset);
var selectedAsset =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
var newAssetsForAlbum =
@@ -113,7 +111,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}",
cacheKey: asset.id,
width: 150,
height: 150,
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,

View File

@@ -1,6 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -15,8 +14,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
return GestureDetector(
@@ -26,7 +23,7 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
child: Stack(
children: [
CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}",
cacheKey: asset.id,
width: 500,
height: 500,
memCacheHeight: 500,

View File

@@ -35,13 +35,7 @@ class SharingSliverAppBar extends StatelessWidget {
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: TextButton.icon(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).primaryColor.withAlpha(20),
),
// foregroundColor: MaterialStateProperty.all(Colors.white),
),
child: ElevatedButton.icon(
onPressed: () {
AutoRouter.of(context)
.push(CreateAlbumRoute(isSharedAlbum: true));
@@ -52,8 +46,12 @@ class SharingSliverAppBar extends StatelessWidget {
),
label: const Text(
"sharing_silver_appbar_create_shared_album",
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
// color: Theme.of(context).primaryColor,
),
).tr(),
),
),
@@ -61,13 +59,7 @@ class SharingSliverAppBar extends StatelessWidget {
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: TextButton.icon(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).primaryColor.withAlpha(20),
),
// foregroundColor: MaterialStateProperty.all(Colors.white),
),
child: ElevatedButton.icon(
onPressed: null,
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
@@ -75,8 +67,11 @@ class SharingSliverAppBar extends StatelessWidget {
),
label: const Text(
"sharing_silver_appbar_share_partner",
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
),
maxLines: 1,
).tr(),
),
),

View File

@@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
@@ -14,6 +13,8 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
@@ -187,12 +188,17 @@ class AlbumViewerPage extends HookConsumerWidget {
}
Widget _buildImageGrid(AlbumResponseDto albumInfo) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
if (albumInfo.assets.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
@@ -201,9 +207,10 @@ class AlbumViewerPage extends HookConsumerWidget {
return AlbumViewerThumbnail(
asset: albumInfo.assets[index],
assetList: albumInfo.assets,
showStorageIndicator: showStorageIndicator,
);
},
childCount: albumInfo.assets.length,
childCount: albumInfo.assetCount,
),
),
);
@@ -242,7 +249,7 @@ class AlbumViewerPage extends HookConsumerWidget {
titleFocusNode.unfocus();
},
child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
@@ -255,7 +262,7 @@ class AlbumViewerPage extends HookConsumerWidget {
minHeight: 50,
maxHeight: 50,
child: Container(
color: immichBackgroundColor,
color: Theme.of(context).scaffoldBackgroundColor,
child: _buildControlButton(albumInfo),
),
),

View File

@@ -43,7 +43,7 @@ class AssetSelectionPage extends HookConsumerWidget {
return Stack(
children: [
DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(

View File

@@ -27,6 +27,7 @@ class CreateAlbumPage extends HookConsumerWidget {
final isAlbumTitleEmpty = useState(true);
final selectedAssets =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
_showSelectUserPage() {
AutoRouter.of(context).push(const SelectUserForSharingRoute());
@@ -75,9 +76,12 @@ class CreateAlbumPage extends HookConsumerWidget {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 200, left: 18),
child: const Text(
child: Text(
'create_shared_album_page_share_add_assets',
style: TextStyle(fontSize: 12),
style: Theme.of(context).textTheme.headline2?.copyWith(
fontSize: 12,
fontWeight: FontWeight.normal,
),
).tr(),
),
);
@@ -96,24 +100,28 @@ class CreateAlbumPage extends HookConsumerWidget {
alignment: Alignment.centerLeft,
padding:
const EdgeInsets.symmetric(vertical: 22, horizontal: 16),
side: const BorderSide(
color: Color.fromARGB(255, 206, 206, 206),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 63, 63, 63)
: const Color.fromARGB(255, 206, 206, 206),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
),
onPressed: _onSelectPhotosButtonPressed,
icon: const Icon(Icons.add_rounded),
icon: Icon(
Icons.add_rounded,
color: Theme.of(context).primaryColor,
),
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
'create_shared_album_page_share_select_photos',
style: TextStyle(
fontSize: 16,
color: Colors.grey[700],
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
),
@@ -190,6 +198,7 @@ class CreateAlbumPage extends HookConsumerWidget {
appBar: AppBar(
elevation: 0,
centerTitle: false,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
leading: IconButton(
onPressed: () {
ref.watch(assetSelectionProvider.notifier).removeAll();
@@ -197,9 +206,11 @@ class CreateAlbumPage extends HookConsumerWidget {
},
icon: const Icon(Icons.close_rounded),
),
title: const Text(
title: Text(
'share_create_album',
style: TextStyle(color: Colors.black),
style: Theme.of(context).textTheme.headline2?.copyWith(
color: Theme.of(context).primaryColor,
),
).tr(),
actions: [
if (isSharedAlbum)
@@ -209,8 +220,9 @@ class CreateAlbumPage extends HookConsumerWidget {
: null,
child: Text(
'create_shared_album_page_share'.tr(),
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
@@ -234,9 +246,9 @@ class CreateAlbumPage extends HookConsumerWidget {
child: CustomScrollView(
slivers: [
SliverAppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 5,
automaticallyImplyLeading: false,
// leading: Container(),
pinned: true,
floating: false,
bottom: PreferredSize(

View File

@@ -23,7 +23,7 @@ class LibraryPage extends HookConsumerWidget {
);
Widget _buildAppBar() {
return SliverAppBar(
return const SliverAppBar(
centerTitle: true,
floating: true,
pinned: false,
@@ -35,7 +35,6 @@ class LibraryPage extends HookConsumerWidget {
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
);
@@ -72,7 +71,6 @@ class LibraryPage extends HookConsumerWidget {
child: const Text(
'library_page_new_album',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
).tr(),

View File

@@ -136,9 +136,9 @@ class SelectUserForSharingPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text(
title: Text(
'share_invite',
style: TextStyle(color: Colors.black),
style: TextStyle(color: Theme.of(context).primaryColor),
).tr(),
elevation: 0,
centerTitle: false,
@@ -150,11 +150,18 @@ class SelectUserForSharingPage extends HookConsumerWidget {
),
actions: [
TextButton(
style: TextButton.styleFrom(
primary: Theme.of(context).primaryColor,
),
onPressed:
sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
child: const Text(
"share_create_album",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
// color: Theme.of(context).primaryColor,
),
).tr(),
)
],

View File

@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -8,8 +9,8 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:transparent_image/transparent_image.dart';
class SharingPage extends HookConsumerWidget {
const SharingPage({Key? key}) : super(key: key);
@@ -32,40 +33,33 @@ class SharingPage extends HookConsumerWidget {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
String thumbnailUrl = sharedAlbums[index].albumThumbnailAssetId !=
null
? "$thumbnailRequestUrl/${sharedAlbums[index].albumThumbnailAssetId}"
: "https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60";
final album = sharedAlbums[index];
return ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: FadeInImage(
child: CachedNetworkImage(
width: 60,
height: 60,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: MemoryImage(kTransparentImage),
image: NetworkImage(
thumbnailUrl,
headers: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
),
imageUrl: getAlbumThumbnailUrl(album),
cacheKey: album.albumThumbnailAssetId,
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 200),
fadeOutDuration: const Duration(milliseconds: 200),
),
),
title: Text(
sharedAlbums[index].albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
onTap: () {
AutoRouter.of(context)
@@ -87,7 +81,7 @@ class SharingPage extends HookConsumerWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), // if you need this
side: const BorderSide(
color: Colors.black12,
color: Colors.grey,
width: 1,
),
),
@@ -102,25 +96,21 @@ class SharingPage extends HookConsumerWidget {
child: Icon(
Icons.offline_share_outlined,
size: 50,
color: Theme.of(context).primaryColor.withAlpha(200),
color: Theme.of(context).primaryColor,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'sharing_page_empty_list',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.headline3,
).tr(),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'sharing_page_description',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
style: Theme.of(context).textTheme.bodyMedium,
).tr(),
),
],

View File

@@ -1,5 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
@@ -11,6 +10,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
bool _zoomedIn = false;
static const int swipeThreshold = 100;
late CachedNetworkImageProvider fullProvider;
late CachedNetworkImageProvider previewProvider;
late CachedNetworkImageProvider thumbnailProvider;
@override
Widget build(BuildContext context) {
@@ -55,19 +57,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
widget.isZoomedFunction();
}
void _fireStartLoadingEvent() {
if (widget.onLoadingStart != null) widget.onLoadingStart!();
}
void _fireFinishedLoadingEvent() {
if (widget.onLoadingCompleted != null) widget.onLoadingCompleted!();
}
CachedNetworkImageProvider _authorizedImageProvider(String url) {
CachedNetworkImageProvider _authorizedImageProvider(
String url,
String cacheKey,
) {
return CachedNetworkImageProvider(
url,
headers: {"Authorization": widget.authToken},
cacheKey: url,
cacheKey: cacheKey,
);
}
@@ -88,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
if (!mounted) return;
if (newStatus != _RemoteImageStatus.full) {
_fireStartLoadingEvent();
} else {
_fireFinishedLoadingEvent();
}
setState(() {
_status = newStatus;
_imageProvider = provider;
@@ -101,8 +92,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
}
void _loadImages() {
CachedNetworkImageProvider thumbnailProvider =
_authorizedImageProvider(widget.thumbnailUrl);
thumbnailProvider = _authorizedImageProvider(
widget.thumbnailUrl,
widget.cacheKey,
);
_imageProvider = thumbnailProvider;
thumbnailProvider.resolve(const ImageConfiguration()).addListener(
@@ -115,8 +108,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
);
if (widget.previewUrl != null) {
CachedNetworkImageProvider previewProvider =
_authorizedImageProvider(widget.previewUrl!);
previewProvider = _authorizedImageProvider(
widget.previewUrl!,
"${widget.cacheKey}_previewStage",
);
previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.preview, previewProvider);
@@ -124,8 +119,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
);
}
CachedNetworkImageProvider fullProvider =
_authorizedImageProvider(widget.imageUrl);
fullProvider = _authorizedImageProvider(
widget.imageUrl,
"${widget.cacheKey}_fullStage",
);
fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.full, fullProvider);
@@ -135,32 +132,45 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
@override
void initState() {
_loadImages();
super.initState();
_loadImages();
}
@override
void dispose() async {
super.dispose();
if (_status == _RemoteImageStatus.full) {
await fullProvider.evict();
} else if (_status == _RemoteImageStatus.preview) {
await previewProvider.evict();
} else if (_status == _RemoteImageStatus.thumbnail) {
await thumbnailProvider.evict();
}
await _imageProvider.evict();
}
}
class RemotePhotoView extends StatefulWidget {
const RemotePhotoView(
{Key? key,
required this.thumbnailUrl,
required this.imageUrl,
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.onSwipeDown,
required this.onSwipeUp,
this.previewUrl,
this.onLoadingCompleted,
this.onLoadingStart})
: super(key: key);
const RemotePhotoView({
Key? key,
required this.thumbnailUrl,
required this.imageUrl,
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.onSwipeDown,
required this.onSwipeUp,
this.previewUrl,
required this.cacheKey,
}) : super(key: key);
final String thumbnailUrl;
final String imageUrl;
final String authToken;
final String? previewUrl;
final Function? onLoadingCompleted;
final Function? onLoadingStart;
final String cacheKey;
final void Function() onSwipeDown;
final void Function() onSwipeUp;

View File

@@ -1,5 +1,3 @@
import 'dart:developer';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -12,7 +10,7 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
required this.onMoreInfoPressed,
required this.onDownloadPressed,
required this.onSharePressed,
this.loading = false
this.loading = false,
}) : super(key: key);
final AssetResponseDto asset;
@@ -28,42 +26,37 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
return AppBar(
foregroundColor: Colors.grey[100],
toolbarHeight: 60,
backgroundColor: Colors.black,
backgroundColor: Colors.transparent,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(
icon: Icon(
Icons.arrow_back_ios_new_rounded,
size: 20.0,
color: Colors.grey[200],
),
),
actions: [
if (loading) Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 15.0),
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2.0),
if (loading)
Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 15.0),
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2.0),
),
),
) ,
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onDownloadPressed();
},
icon: const Icon(Icons.cloud_download_rounded),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
log("favorite");
},
icon: asset.isFavorite
? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_border_rounded),
icon: Icon(
Icons.cloud_download_rounded,
color: Colors.grey[200],
),
),
IconButton(
iconSize: iconSize,
@@ -71,7 +64,10 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
onPressed: () {
onSharePressed();
},
icon: const Icon(Icons.share),
icon: Icon(
Icons.share,
color: Colors.grey[200],
),
),
IconButton(
iconSize: iconSize,
@@ -79,7 +75,10 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
onPressed: () {
onMoreInfoPressed();
},
icon: const Icon(Icons.more_horiz_rounded),
icon: Icon(
Icons.more_horiz_rounded,
color: Colors.grey[200],
),
)
],
);

View File

@@ -11,6 +11,8 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:openapi/api.dart';
// ignore: must_be_immutable
@@ -18,8 +20,6 @@ class GalleryViewerPage extends HookConsumerWidget {
late List<AssetResponseDto> assetList;
final AssetResponseDto asset;
static const _threeStageLoading = false;
GalleryViewerPage({
Key? key,
required this.assetList,
@@ -27,21 +27,35 @@ class GalleryViewerPage extends HookConsumerWidget {
}) : super(key: key);
AssetResponseDto? assetDetail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final Box<dynamic> box = Hive.box(userInfoBox);
final appSettingService = ref.watch(appSettingsServiceProvider);
final threeStageLoading = useState(false);
final loading = useState(false);
final isZoomed = useState<bool>(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
int indexOfAsset = assetList.indexOf(asset);
final loading = useState(false);
@override
void initState(int index) {
indexOfAsset = index;
}
PageController controller =
PageController(initialPage: assetList.indexOf(asset));
useEffect(
() {
threeStageLoading.value = appSettingService
.getSetting<bool>(AppSettingsEnum.threeStageLoading);
return null;
},
[],
);
@override
initState(int index) {
indexOfAsset = index;
}
getAssetExif() async {
assetDetail = await ref
.watch(assetServiceProvider)
@@ -60,9 +74,6 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
final isZoomed = useState<bool>(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
//make isZoomed listener call instead
void isZoomedMethod() {
if (isZoomedListener.value) {
@@ -84,7 +95,8 @@ class GalleryViewerPage extends HookConsumerWidget {
ref
.watch(imageViewerStateProvider.notifier)
.downloadAsset(assetList[indexOfAsset], context);
}, onSharePressed: () {
},
onSharePressed: () {
ref
.watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset], context);
@@ -101,17 +113,17 @@ class GalleryViewerPage extends HookConsumerWidget {
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
initState(index);
getAssetExif();
if (assetList[index].type == AssetTypeEnum.IMAGE) {
return ImageViewerPage(
authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener,
onLoadingCompleted: () => loading.value = false,
onLoadingStart: () => loading.value = _threeStageLoading,
asset: assetList[index],
heroTag: assetList[index].id,
threeStageLoading: _threeStageLoading
threeStageLoading: threeStageLoading.value,
);
} else {
return SwipeDetector(

View File

@@ -18,8 +18,6 @@ class ImageViewerPage extends HookConsumerWidget {
final String authToken;
final ValueNotifier<bool> isZoomedListener;
final void Function() isZoomedFunction;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading;
ImageViewerPage({
@@ -29,12 +27,11 @@ class ImageViewerPage extends HookConsumerWidget {
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading,
}) : super(key: key);
AssetResponseDto? assetDetail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus =
@@ -71,18 +68,18 @@ class ImageViewerPage extends HookConsumerWidget {
child: Hero(
tag: heroTag,
child: RemotePhotoView(
thumbnailUrl: getThumbnailUrl(asset),
imageUrl: getImageUrl(asset),
previewUrl: threeStageLoading
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
: null,
authToken: authToken,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(),
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart),
thumbnailUrl: getThumbnailUrl(asset),
cacheKey: asset.id,
imageUrl: getImageUrl(asset),
previewUrl: threeStageLoading
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
: null,
authToken: authToken,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(),
),
),
),
if (downloadAssetStatus == DownloadAssetStatus.loading)

View File

@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
_createChewieController() {
chewieController = ChewieController(
showOptions: true,
showControlsOnInitialize: true,
showControlsOnInitialize: false,
videoPlayerController: videoPlayerController,
autoPlay: true,
autoInitialize: true,

View File

@@ -0,0 +1,456 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/localization.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/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:photo_manager/photo_manager.dart';
final backgroundServiceProvider = Provider(
(ref) => BackgroundService(),
);
/// Background backup service
class BackgroundService {
static const String _portNameLock = "immichLock";
BackgroundService();
static const MethodChannel _foregroundChannel =
MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel');
bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken;
bool _canceledBySystem = false;
int _wantsLockTime = 0;
bool _hasLock = false;
SendPort? _waitingIsolate;
ReceivePort? _rp;
bool _errorGracePeriodExceeded = true;
bool get isBackgroundInitialized {
return _isBackgroundInitialized;
}
/// Ensures that the background service is enqueued if enabled in settings
Future<bool> resumeServiceIfEnabled() async {
return await isBackgroundBackupEnabled() && await enableService();
}
/// Enqueues the background service
Future<bool> enableService({bool immediate = false}) async {
if (!Platform.isAndroid) {
return true;
}
try {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
return ok;
} catch (error) {
return false;
}
}
/// Configures the background service
Future<bool> configureService({
bool requireUnmetered = true,
bool requireCharging = false,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
final bool ok = await _foregroundChannel.invokeMethod(
'configure',
[requireUnmetered, requireCharging],
);
return ok;
} catch (error) {
return false;
}
}
/// Cancels the background service (if currently running) and removes it from work queue
Future<bool> disableService() async {
if (!Platform.isAndroid) {
return true;
}
try {
final ok = await _foregroundChannel.invokeMethod('disable');
return ok;
} catch (error) {
return false;
}
}
/// Returns `true` if the background service is enabled
Future<bool> isBackgroundBackupEnabled() async {
if (!Platform.isAndroid) {
return false;
}
try {
return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) {
return false;
}
}
/// Returns `true` if battery optimizations are disabled
Future<bool> isIgnoringBatteryOptimizations() async {
if (!Platform.isAndroid) {
return true;
}
try {
return await _foregroundChannel
.invokeMethod('isIgnoringBatteryOptimizations');
} catch (error) {
return false;
}
}
/// Updates the notification shown by the background service
Future<bool> _updateNotification({
required String title,
String? content,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized) {
return await _backgroundChannel
.invokeMethod('updateNotification', [title, content]);
}
} catch (error) {
debugPrint("[_updateNotification] failed to communicate with plugin");
}
return Future.value(false);
}
/// Shows a new priority notification
Future<bool> _showErrorNotification({
required String title,
String? content,
String? individualTag,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
return await _backgroundChannel
.invokeMethod('showError', [title, content, individualTag]);
}
} catch (error) {
debugPrint("[_showErrorNotification] failed to communicate with plugin");
}
return false;
}
Future<bool> _clearErrorNotifications() async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized) {
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
}
} catch (error) {
debugPrint(
"[_clearErrorNotifications] failed to communicate with plugin",
);
}
return false;
}
/// await to ensure this thread (foreground or background) has exclusive access
Future<bool> acquireLock() async {
if (!Platform.isAndroid) {
return true;
}
final int lockTime = Timeline.now;
_wantsLockTime = lockTime;
final ReceivePort rp = ReceivePort(_portNameLock);
_rp = rp;
final SendPort sp = rp.sendPort;
while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
try {
await _checkLockReleasedWithHeartbeat(lockTime);
} catch (error) {
return false;
}
if (_wantsLockTime != lockTime) {
return false;
}
}
_hasLock = true;
rp.listen(_heartbeatListener);
return true;
}
Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
if (other != null) {
final ReceivePort tempRp = ReceivePort();
final SendPort tempSp = tempRp.sendPort;
final bs = tempRp.asBroadcastStream();
while (_wantsLockTime == lockTime) {
other.send(tempSp);
final dynamic answer = await bs.first
.timeout(const Duration(seconds: 5), onTimeout: () => null);
if (_wantsLockTime != lockTime) {
break;
}
if (answer == null) {
// other isolate failed to answer, assuming it exited without releasing the lock
if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
IsolateNameServer.removePortNameMapping(_portNameLock);
}
break;
} else if (answer == true) {
// other isolate released the lock
break;
} else if (answer == false) {
// other isolate is still active
}
final dynamic isFinished = await bs.first
.timeout(const Duration(seconds: 5), onTimeout: () => false);
if (isFinished == true) {
break;
}
}
tempRp.close();
}
}
void _heartbeatListener(dynamic msg) {
if (msg is SendPort) {
_waitingIsolate = msg;
msg.send(false);
}
}
/// releases the exclusive access lock
void releaseLock() {
if (!Platform.isAndroid) {
return;
}
_wantsLockTime = 0;
if (_hasLock) {
IsolateNameServer.removePortNameMapping(_portNameLock);
_waitingIsolate?.send(true);
_waitingIsolate = null;
_hasLock = false;
}
_rp?.close();
_rp = null;
}
void _setupBackgroundCallHandler() {
_backgroundChannel.setMethodCallHandler(_callHandler);
_isBackgroundInitialized = true;
_backgroundChannel.invokeMethod('initialized');
}
Future<bool> _callHandler(MethodCall call) async {
switch (call.method) {
case "onAssetsChanged":
final Future<bool> translationsLoaded = loadTranslations();
try {
final bool hasAccess = await acquireLock();
if (!hasAccess) {
debugPrint("[_callHandler] could not acquire lock, exiting");
return false;
}
await translationsLoaded;
final bool ok = await _onAssetsChanged();
return ok;
} catch (error) {
debugPrint(error.toString());
return false;
} finally {
await Hive.close();
releaseLock();
}
case "systemStop":
_canceledBySystem = true;
_cancellationToken?.cancel();
return true;
default:
debugPrint("Unknown method ${call.method}");
return false;
}
}
Future<bool> _onAssetsChanged() async {
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox(userSettingInfoBox);
await Hive.openBox(backgroundBackupInfoBox);
ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
BackupService backupService = BackupService(apiService);
final Box<HiveBackupAlbums> box =
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
if (backupAlbumInfo == null) {
_clearErrorNotifications();
return true;
}
await PhotoManager.setIgnorePermissionCheck(true);
do {
final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
if (backupOk) {
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
return false;
}
// check for new assets added while performing backup
} while (true ==
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
return true;
}
Future<bool> _runBackup(
BackupService backupService,
HiveBackupAlbums backupAlbumInfo,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
if (_canceledBySystem) {
return false;
}
List<AssetEntity> toUpload =
await backupService.buildUploadCandidates(backupAlbumInfo);
try {
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
} catch (e) {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_connection_failed_message".tr(),
);
return false;
}
if (_canceledBySystem) {
return false;
}
if (toUpload.isEmpty) {
_clearErrorNotifications();
return true;
}
_cancellationToken = CancellationToken();
final bool ok = await backupService.backupAsset(
toUpload,
_cancellationToken!,
_onAssetUploaded,
_onProgress,
_onSetCurrentBackupAsset,
_onBackupError,
);
if (ok) {
_clearErrorNotifications();
} else {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_backup_failed_message".tr(),
);
}
return ok;
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
debugPrint("Uploaded $deviceAssetId from $deviceId");
}
void _onProgress(int sent, int total) {}
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
_showErrorNotification(
title: "Upload failed",
content: "backup_background_service_upload_failure_notification"
.tr(args: [errorAssetInfo.fileName]),
individualTag: errorAssetInfo.id,
);
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
_updateNotification(
title: "backup_background_service_in_progress_notification".tr(),
content: "backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]),
);
}
bool _isErrorGracePeriodExceeded() {
final int value = AppSettingsService()
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
if (value == 0) {
return true;
} else if (value == 5) {
return false;
}
final DateTime? failedSince =
Hive.box(backgroundBackupInfoBox).get(backupFailedSince);
if (failedSince == null) {
return false;
}
final Duration duration = DateTime.now().difference(failedSince);
if (value == 1) {
return duration > const Duration(minutes: 30);
} else if (value == 2) {
return duration > const Duration(hours: 2);
} else if (value == 3) {
return duration > const Duration(hours: 8);
} else if (value == 4) {
return duration > const Duration(hours: 24);
}
assert(false, "Invalid value");
return true;
}
}
/// entry point called by Kotlin/Java code; needs to be a top-level function
@pragma('vm:entry-point')
void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized();
BackgroundService backgroundService = BackgroundService();
backgroundService._setupBackgroundCallHandler();
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
import 'package:easy_localization/src/asset_loader.dart';
import 'package:easy_localization/src/easy_localization_controller.dart';
import 'package:easy_localization/src/localization.dart';
import 'package:immich_mobile/constants/locales.dart';
/// Workaround to manually load translations in another Isolate
Future<bool> loadTranslations() async {
await EasyLocalizationController.initEasyLocation();
final controller = EasyLocalizationController(
supportedLocales: locales,
useFallbackTranslations: true,
saveLocale: true,
assetLoader: const RootBundleAssetLoader(),
path: translationsPath,
useOnlyLangCode: false,
onLoadError: (e) => debugPrint(e.toString()),
fallbackLocale: locales.first,
);
await controller.loadTranslations();
return Localization.load(controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations);
}

View File

@@ -4,35 +4,45 @@ import 'package:photo_manager/photo_manager.dart';
class AvailableAlbum {
final AssetPathEntity albumEntity;
final DateTime? lastBackup;
final Uint8List? thumbnailData;
AvailableAlbum({
required this.albumEntity,
this.lastBackup,
this.thumbnailData,
});
AvailableAlbum copyWith({
AssetPathEntity? albumEntity,
DateTime? lastBackup,
Uint8List? thumbnailData,
}) {
return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity,
lastBackup: lastBackup ?? this.lastBackup,
thumbnailData: thumbnailData ?? this.thumbnailData,
);
}
String get name => albumEntity.name;
Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => albumEntity.id;
bool get isAll => albumEntity.isAll;
@override
String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AvailableAlbum &&
other.albumEntity == albumEntity &&
other.thumbnailData == thumbnailData;
return other is AvailableAlbum && other.albumEntity == albumEntity;
}
@override
int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode;
int get hashCode => albumEntity.hashCode;
}

View File

@@ -6,7 +6,7 @@ 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/current_upload_asset.model.dart';
enum BackUpProgressEnum { idle, inProgress, done }
enum BackUpProgressEnum { idle, inProgress, inBackground, done }
class BackUpState {
// enum
@@ -15,11 +15,14 @@ class BackUpState {
final double progressInPercentage;
final CancellationToken cancelToken;
final ServerInfoResponseDto serverInfo;
final bool backgroundBackup;
final bool backupRequireWifi;
final bool backupRequireCharging;
/// All available albums on the device
final List<AvailableAlbum> availableAlbums;
final Set<AssetPathEntity> selectedBackupAlbums;
final Set<AssetPathEntity> excludedBackupAlbums;
final Set<AvailableAlbum> selectedBackupAlbums;
final Set<AvailableAlbum> excludedBackupAlbums;
/// Assets that are not overlapping in selected backup albums and excluded backup albums
final Set<AssetEntity> allUniqueAssets;
@@ -36,6 +39,9 @@ class BackUpState {
required this.progressInPercentage,
required this.cancelToken,
required this.serverInfo,
required this.backgroundBackup,
required this.backupRequireWifi,
required this.backupRequireCharging,
required this.availableAlbums,
required this.selectedBackupAlbums,
required this.excludedBackupAlbums,
@@ -50,9 +56,12 @@ class BackUpState {
double? progressInPercentage,
CancellationToken? cancelToken,
ServerInfoResponseDto? serverInfo,
bool? backgroundBackup,
bool? backupRequireWifi,
bool? backupRequireCharging,
List<AvailableAlbum>? availableAlbums,
Set<AssetPathEntity>? selectedBackupAlbums,
Set<AssetPathEntity>? excludedBackupAlbums,
Set<AvailableAlbum>? selectedBackupAlbums,
Set<AvailableAlbum>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset,
@@ -63,6 +72,10 @@ class BackUpState {
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
backupRequireCharging:
backupRequireCharging ?? this.backupRequireCharging,
availableAlbums: availableAlbums ?? this.availableAlbums,
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
@@ -75,7 +88,7 @@ class BackUpState {
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
}
@override
@@ -89,6 +102,9 @@ class BackUpState {
other.progressInPercentage == progressInPercentage &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
other.backgroundBackup == backgroundBackup &&
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
collectionEquals(other.availableAlbums, availableAlbums) &&
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
@@ -107,6 +123,9 @@ class BackUpState {
progressInPercentage.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
backgroundBackup.hashCode ^
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^

View File

@@ -13,9 +13,17 @@ class HiveBackupAlbums {
@HiveField(1)
List<String> excludedAlbumsIds;
@HiveField(2, defaultValue: [])
List<DateTime> lastSelectedBackupTime;
@HiveField(3, defaultValue: [])
List<DateTime> lastExcludedBackupTime;
HiveBackupAlbums({
required this.selectedAlbumIds,
required this.excludedAlbumsIds,
required this.lastSelectedBackupTime,
required this.lastExcludedBackupTime,
});
@override
@@ -25,10 +33,16 @@ class HiveBackupAlbums {
HiveBackupAlbums copyWith({
List<String>? selectedAlbumIds,
List<String>? excludedAlbumsIds,
List<DateTime>? lastSelectedBackupTime,
List<DateTime>? lastExcludedBackupTime,
}) {
return HiveBackupAlbums(
selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
lastSelectedBackupTime:
lastSelectedBackupTime ?? this.lastSelectedBackupTime,
lastExcludedBackupTime:
lastExcludedBackupTime ?? this.lastExcludedBackupTime,
);
}
@@ -37,6 +51,8 @@ class HiveBackupAlbums {
result.addAll({'selectedAlbumIds': selectedAlbumIds});
result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
result.addAll({'lastSelectedBackupTime': lastSelectedBackupTime});
result.addAll({'lastExcludedBackupTime': lastExcludedBackupTime});
return result;
}
@@ -45,6 +61,10 @@ class HiveBackupAlbums {
return HiveBackupAlbums(
selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
lastSelectedBackupTime:
List<DateTime>.from(map['lastSelectedBackupTime']),
lastExcludedBackupTime:
List<DateTime>.from(map['lastExcludedBackupTime']),
);
}
@@ -60,9 +80,15 @@ class HiveBackupAlbums {
return other is HiveBackupAlbums &&
listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
listEquals(other.excludedAlbumsIds, excludedAlbumsIds);
listEquals(other.excludedAlbumsIds, excludedAlbumsIds) &&
listEquals(other.lastSelectedBackupTime, lastSelectedBackupTime) &&
listEquals(other.lastExcludedBackupTime, lastExcludedBackupTime);
}
@override
int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode;
int get hashCode =>
selectedAlbumIds.hashCode ^
excludedAlbumsIds.hashCode ^
lastSelectedBackupTime.hashCode ^
lastExcludedBackupTime.hashCode;
}

View File

@@ -19,17 +19,25 @@ class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
return HiveBackupAlbums(
selectedAlbumIds: (fields[0] as List).cast<String>(),
excludedAlbumsIds: (fields[1] as List).cast<String>(),
lastSelectedBackupTime:
fields[2] == null ? [] : (fields[2] as List).cast<DateTime>(),
lastExcludedBackupTime:
fields[3] == null ? [] : (fields[3] as List).cast<DateTime>(),
);
}
@override
void write(BinaryWriter writer, HiveBackupAlbums obj) {
writer
..writeByte(2)
..writeByte(4)
..writeByte(0)
..write(obj.selectedAlbumIds)
..writeByte(1)
..write(obj.excludedAlbumsIds);
..write(obj.excludedAlbumsIds)
..writeByte(2)
..write(obj.lastSelectedBackupTime)
..writeByte(3)
..write(obj.lastExcludedBackupTime);
}
@override

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
@@ -9,9 +11,11 @@ import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.d
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/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -21,6 +25,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._backupService,
this._serverInfoService,
this._authState,
this._backgroundService,
this.ref,
) : super(
BackUpState(
@@ -28,6 +33,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
allAssetsInDatabase: const [],
progressInPercentage: 0,
cancelToken: CancellationToken(),
backgroundBackup: false,
backupRequireWifi: true,
backupRequireCharging: false,
serverInfo: ServerInfoResponseDto(
diskAvailable: "0",
diskAvailableRaw: 0,
@@ -56,6 +64,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final AuthenticationState _authState;
final BackgroundService _backgroundService;
final Ref ref;
///
@@ -66,7 +75,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// We have method to include and exclude albums
/// The total unique assets will be used for backing mechanism
///
void addAlbumForBackup(AssetPathEntity album) {
void addAlbumForBackup(AvailableAlbum album) {
if (state.excludedBackupAlbums.contains(album)) {
removeExcludedAlbumForBackup(album);
}
@@ -76,7 +85,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void addExcludedAlbumForBackup(AssetPathEntity album) {
void addExcludedAlbumForBackup(AvailableAlbum album) {
if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album);
}
@@ -85,8 +94,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void removeAlbumForBackup(AssetPathEntity album) {
Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
void removeAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
currentSelectedAlbums.removeWhere((a) => a == album);
@@ -94,8 +103,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void removeExcludedAlbumForBackup(AssetPathEntity album) {
Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
void removeExcludedAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
currentExcludedAlbums.removeWhere((a) => a == album);
@@ -103,6 +112,60 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void configureBackgroundBackup({
bool? enabled,
bool? requireWifi,
bool? requireCharging,
required void Function(String msg) onError,
required void Function() onBatteryInfo,
}) async {
assert(enabled != null || requireWifi != null || requireCharging != null);
if (Platform.isAndroid) {
final bool wasEnabled = state.backgroundBackup;
final bool wasWifi = state.backupRequireWifi;
final bool wasCharing = state.backupRequireCharging;
state = state.copyWith(
backgroundBackup: enabled,
backupRequireWifi: requireWifi,
backupRequireCharging: requireCharging,
);
if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo();
}
success &= await _backgroundService.enableService(immediate: true);
}
success &= success &&
await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
);
if (success) {
await Hive.box(backgroundBackupInfoBox)
.put(backupRequireWifi, state.backupRequireWifi);
await Hive.box(backgroundBackupInfoBox)
.put(backupRequireCharging, state.backupRequireCharging);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
backupRequireWifi: wasWifi,
backupRequireCharging: wasCharing,
);
onError("backup_controller_page_background_configure_error");
}
} else {
final bool success = await _backgroundService.disableService();
if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error");
}
}
}
}
///
/// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage
@@ -120,17 +183,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
var assetList =
await album.getAssetListRange(start: 0, end: album.assetCount);
var assetCountInAlbum = await album.assetCountAsync;
if (assetCountInAlbum > 0) {
var assetList =
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first;
var thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first;
var thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
}
availableAlbums.add(availableAlbum);
}
availableAlbums.add(availableAlbum);
}
state = state.copyWith(availableAlbums: availableAlbums);
@@ -144,6 +211,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
defaultValue: HiveBackupAlbums(
selectedAlbumIds: [],
excludedAlbumsIds: [],
lastSelectedBackupTime: [],
lastExcludedBackupTime: [],
),
);
@@ -173,6 +242,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
HiveBackupAlbums(
selectedAlbumIds: [albumHasAllAssets.id],
excludedAlbumsIds: [],
lastSelectedBackupTime: [
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)
],
lastExcludedBackupTime: [],
),
);
@@ -181,19 +254,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Generate AssetPathEntity from id to add to local state
try {
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
state = state.copyWith(
selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset},
Set<AvailableAlbum> selectedAlbums = {};
for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) {
var albumAsset =
await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]);
selectedAlbums.add(
AvailableAlbum(
albumEntity: albumAsset,
lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i
? backupAlbumInfo.lastSelectedBackupTime[i]
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
),
);
}
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
state = state.copyWith(
excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset},
Set<AvailableAlbum> excludedAlbums = {};
for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) {
var albumAsset =
await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]);
excludedAlbums.add(
AvailableAlbum(
albumEntity: albumAsset,
lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i
? backupAlbumInfo.lastExcludedBackupTime[i]
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
),
);
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
} catch (e) {
debugPrint("[ERROR] Failed to generate album from id $e");
}
@@ -209,14 +300,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) {
var assets =
await album.getAssetListRange(start: 0, end: album.assetCount);
var assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromSelectedAlbums.addAll(assets);
}
for (var album in state.excludedBackupAlbums) {
var assets =
await album.getAssetListRange(start: 0, end: album.assetCount);
var assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets);
}
@@ -263,12 +358,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// and then update the UI according to those information
///
Future<void> getBackupInfo() async {
await Future.wait([
_getBackupAlbumsInfo(),
_updateServerInfo(),
]);
await _updateBackupAssetCount();
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await _updateServerInfo();
await _updateBackupAssetCount();
}
}
///
@@ -276,6 +372,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Hive database
///
void _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
Box<HiveBackupAlbums> backupAlbumInfoBox =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
backupAlbumInfoBox.put(
@@ -283,6 +380,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
HiveBackupAlbums(
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
lastSelectedBackupTime: state.selectedBackupAlbums
.map((e) => e.lastBackup ?? epoch)
.toList(),
lastExcludedBackupTime: state.excludedBackupAlbums
.map((e) => e.lastBackup ?? epoch)
.toList(),
),
);
}
@@ -290,7 +393,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
///
/// Invoke backup process
///
void startBackupProcess() async {
Future<void> startBackupProcess() async {
assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
await getBackupInfo();
@@ -318,7 +422,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
_onAssetUploaded,
@@ -326,6 +430,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_onSetCurrentBackupAsset,
_onBackupError,
);
await _notifyBackgroundServiceCanRun();
} else {
PhotoManager.openSetting();
}
@@ -340,6 +445,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
void cancelBackup() {
if (state.backupProgress != BackUpProgressEnum.inProgress) {
_notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
@@ -359,10 +467,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
0) {
final latestAssetBackup =
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
(v, e) => e.isAfter(v) ? e : v,
);
state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
.toSet(),
excludedBackupAlbums: state.excludedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
.toSet(),
backupProgress: BackUpProgressEnum.done,
progressInPercentage: 0.0,
);
_updatePersistentAlbumsSelection();
}
_updateServerInfo();
@@ -385,7 +504,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
void resumeBackup() {
Future<void> _resumeBackup() async {
// Check if user is login
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
@@ -404,13 +523,101 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return;
}
if (state.backupProgress == BackUpProgressEnum.inBackground) {
debugPrint("[resumeBackup] Background backup is running - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
startBackupProcess();
await startBackupProcess();
}
return;
}
Future<void> resumeBackup() async {
if (Platform.isAndroid) {
// assumes the background service is currently running
// if true, waits until it has stopped to update the app state from HiveDB
// before actually resuming backup by calling the internal `_resumeBackup`
final BackUpProgressEnum previous = state.backupProgress;
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
final bool hasLock = await _backgroundService.acquireLock();
if (!hasLock) {
return;
}
Box<HiveBackupAlbums> box =
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
HiveBackupAlbums? albums = box.get(backupInfoKey);
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (albums != null) {
selectedAlbums = _updateAlbumsBackupTime(
selectedAlbums,
albums.selectedAlbumIds,
albums.lastSelectedBackupTime,
);
excludedAlbums = _updateAlbumsBackupTime(
excludedAlbums,
albums.excludedAlbumsIds,
albums.lastExcludedBackupTime,
);
}
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
state = state.copyWith(
backupProgress: previous,
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
backupRequireWifi: backgroundBox.get(backupRequireWifi),
backupRequireCharging: backgroundBox.get(backupRequireCharging),
);
}
return _resumeBackup();
}
Set<AvailableAlbum> _updateAlbumsBackupTime(
Set<AvailableAlbum> albums,
List<String> ids,
List<DateTime> times,
) {
Set<AvailableAlbum> result = {};
for (int i = 0; i < ids.length; i++) {
try {
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
result.add(a.copyWith(lastBackup: times[i]));
} on StateError {
debugPrint("[_updateAlbumBackupTime] failed to find album in state");
}
}
return result;
}
Future<void> _notifyBackgroundServiceCanRun() async {
const allowedStates = [
AppStateEnum.inactive,
AppStateEnum.paused,
AppStateEnum.detached,
];
if (Platform.isAndroid &&
allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
try {
if (Hive.isBoxOpen(hiveBackupInfoBox)) {
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
}
try {
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
await Hive.box(backgroundBackupInfoBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
}
_backgroundService.releaseLock();
}
}
}
final backupProvider =
@@ -419,6 +626,7 @@ final backupProvider =
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider),
ref.watch(backgroundServiceProvider),
ref,
);
});

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -9,6 +10,7 @@ import 'package:immich_mobile/constants/hive_box.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/shared/providers/api.provider.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:openapi/api.dart';
@@ -39,8 +41,131 @@ class BackupService {
}
}
backupAsset(
Set<AssetEntity> assetList,
/// Returns all assets newer than the last successful backup per album
Future<List<AssetEntity>> buildUploadCandidates(
HiveBackupAlbums backupAlbums,
) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(
backupAlbums.selectedAlbumIds,
backupAlbums.lastSelectedBackupTime,
filter,
now,
);
if (selectedAlbums.every((e) => e == null)) {
return [];
}
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
if (allIdx != -1) {
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(
backupAlbums.excludedAlbumsIds,
backupAlbums.lastExcludedBackupTime,
filter,
now,
);
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums.slice(allIdx, allIdx + 1),
backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1),
now,
);
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
backupAlbums.lastExcludedBackupTime,
now,
);
return toAdd.toSet().difference(toRemove.toSet()).toList();
} else {
return await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
backupAlbums.lastSelectedBackupTime,
now,
);
}
}
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<String> albumIds,
List<DateTime> lastBackups,
FilterOptionGroup filter,
DateTime now,
) async {
List<AssetPathEntity?> result = List.filled(albumIds.length, null);
for (int i = 0; i < albumIds.length; i++) {
try {
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: albumIds[i],
optionGroup: filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: lastBackups[i].subtract(const Duration(seconds: 2)),
max: now,
),
),
maxDateTimeToNow: false,
);
result[i] = album;
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
return result;
}
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> albums,
List<DateTime> lastBackup,
DateTime now,
) async {
List<AssetEntity> result = [];
for (int i = 0; i < albums.length; i++) {
final AssetPathEntity? a = albums[i];
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
result.addAll(
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
);
lastBackup[i] = now;
}
}
return result;
}
/// Returns a new list of assets not yet uploaded
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
List<AssetEntity> candidates,
) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
if (candidates.length < 10) {
final List<CheckDuplicateAssetResponseDto?> duplicateResponse =
await Future.wait(
candidates.map(
(e) => _apiService.assetApi.checkDuplicateAsset(
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId),
),
),
);
return candidates
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false)
.toList();
} else {
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return candidates;
}
final Set<String> inDb = allAssetsInDatabase.toSet();
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
}
}
Future<bool> backupAsset(
Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgressCb,
@@ -50,6 +175,7 @@ class BackupService {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file;
bool anyErrors = false;
for (var entity in assetList) {
try {
@@ -134,9 +260,10 @@ class BackupService {
}
} on http.CancelledException {
debugPrint("Backup was cancelled by the user");
return;
return false;
} catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}");
anyErrors = true;
continue;
} finally {
if (Platform.isIOS) {
@@ -144,6 +271,7 @@ class BackupService {
}
}
}
return !anyErrors;
}
String _getAssetType(AssetType assetType) {

View File

@@ -1,19 +1,17 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:photo_manager/photo_manager.dart';
class AlbumInfoCard extends HookConsumerWidget {
final Uint8List? imageData;
final AssetPathEntity albumInfo;
final AvailableAlbum albumInfo;
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo})
: super(key: key);
@@ -24,6 +22,7 @@ class AlbumInfoCard extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
ColorFilter selectedFilter = ColorFilter.mode(
Theme.of(context).primaryColor.withAlpha(100),
@@ -39,11 +38,11 @@ class AlbumInfoCard extends HookConsumerWidget {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text(
label: Text(
"album_info_card_backup_album_included",
style: TextStyle(
fontSize: 10,
color: Colors.white,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
@@ -53,11 +52,11 @@ class AlbumInfoCard extends HookConsumerWidget {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text(
label: Text(
"album_info_card_backup_album_excluded",
style: TextStyle(
fontSize: 10,
color: Colors.white,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
@@ -122,7 +121,7 @@ class AlbumInfoCard extends HookConsumerWidget {
return;
}
if (albumInfo.id == 'isAll') {
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
@@ -141,8 +140,10 @@ class AlbumInfoCard extends HookConsumerWidget {
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // if you need this
side: const BorderSide(
color: Color(0xFFC9C9C9),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 37, 35, 35)
: const Color(0xFFC9C9C9),
width: 1,
),
),
@@ -202,15 +203,23 @@ class AlbumInfoCard extends HookConsumerWidget {
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
albumInfo.assetCount.toString() +
(albumInfo.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
child: FutureBuilder(
builder: ((context, snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data.toString() +
(albumInfo.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: albumInfo.assetCount,
),
)
],
@@ -219,8 +228,9 @@ class AlbumInfoCard extends HookConsumerWidget {
),
IconButton(
onPressed: () {
AutoRouter.of(context)
.push(AlbumPreviewRoute(album: albumInfo));
AutoRouter.of(context).push(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},
icon: Icon(
Icons.image_outlined,

View File

@@ -35,7 +35,7 @@ class BackupInfoCard extends StatelessWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Text(
subtitle,
style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
style: const TextStyle(fontSize: 12),
),
),
trailing: Column(

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -16,6 +17,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
useEffect(
() {
@@ -46,7 +48,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
: const EdgeInsets.all(0),
child: AlbumInfoCard(
imageData: thumbnailData,
albumInfo: availableAlbums[index].albumEntity,
albumInfo: availableAlbums[index],
),
);
}),
@@ -81,14 +83,16 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
label: Text(
album.name,
style: const TextStyle(
style: TextStyle(
fontSize: 10,
color: Colors.white,
color: Theme.of(context).brightness == Brightness.dark
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Theme.of(context).primaryColor,
deleteIconColor: Colors.white,
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
@@ -119,14 +123,15 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
label: Text(
album.name,
style: const TextStyle(
style: TextStyle(
fontSize: 10,
color: Colors.white,
color: isDarkTheme ? Colors.black : immichBackgroundColor,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.red[300],
deleteIconColor: Colors.white,
deleteIconColor:
isDarkTheme ? Colors.black : immichBackgroundColor,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
@@ -154,11 +159,16 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
physics: const ClampingScrollPhysics(),
children: [
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
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
@@ -178,9 +188,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Card(
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(255, 235, 235, 235),
borderRadius: BorderRadius.circular(5),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 0, 0, 0)
: const Color.fromARGB(255, 235, 235, 235),
width: 1,
),
),
@@ -190,12 +202,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
children: [
ListTile(
visualDensity: VisualDensity.compact,
title: Text(
title: const Text(
"backup_album_selection_page_total_assets",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.grey[700],
),
).tr(),
trailing: Text(
@@ -257,11 +268,10 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
content: SingleChildScrollView(
child: ListBody(
children: [
Text(
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
).tr(),
],

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -12,6 +14,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
import 'package:percent_indicator/linear_percent_indicator.dart';
import 'package:url_launcher/url_launcher.dart';
class BackupControllerPage extends HookConsumerWidget {
const BackupControllerPage({Key? key}) : super(key: key);
@@ -20,9 +23,12 @@ class BackupControllerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
AuthenticationState authenticationState = ref.watch(authenticationProvider);
bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length -
backupState.selectedAlbumsBackupAssetsIds.length ==
0
backupState.selectedAlbumsBackupAssetsIds.length ==
0 ||
!hasExclusiveAccess
? false
: true;
@@ -82,7 +88,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
ListTile _buildBackupController() {
ListTile _buildAutoBackupController() {
var backUpOption = authenticationState.deviceInfo.isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
@@ -114,13 +120,7 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: const BorderSide(
width: 1,
color: Color.fromARGB(255, 220, 220, 220),
),
),
child: ElevatedButton(
onPressed: () {
if (isAutoBackup) {
ref
@@ -134,7 +134,10 @@ class BackupControllerPage extends HookConsumerWidget {
},
child: Text(
backupBtnText,
style: const TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
)
@@ -144,6 +147,142 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
void _showErrorToUser(String msg) {
final snackBar = SnackBar(
content: Text(
msg.tr(),
),
backgroundColor: Colors.red,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void _showBatteryOptimizationInfoToUser() {
final buttonTextColor = Theme.of(context).primaryColor;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text(
'backup_controller_page_background_battery_info_title',
).tr(),
content: SingleChildScrollView(
child: const Text(
'backup_controller_page_background_battery_info_message',
).tr(),
),
actions: [
OutlinedButton(
onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication,
),
child: const Text(
"backup_controller_page_background_battery_info_link",
).tr(),
),
ElevatedButton(
child: const Text(
'backup_controller_page_background_battery_info_ok',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
ListTile _buildBackgroundBackupController() {
final bool isBackgroundEnabled = backupState.backgroundBackup;
final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging;
final Color activeColor = Theme.of(context).primaryColor;
return ListTile(
isThreeLine: true,
leading: isBackgroundEnabled
? Icon(
Icons.cloud_sync_rounded,
color: activeColor,
)
: const Icon(Icons.cloud_sync_rounded),
title: Text(
isBackgroundEnabled
? "backup_controller_page_background_is_on"
: "backup_controller_page_background_is_off",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isBackgroundEnabled)
const Text("backup_controller_page_background_description").tr(),
if (isBackgroundEnabled)
SwitchListTile(
title:
const Text("backup_controller_page_background_wifi").tr(),
secondary: Icon(
Icons.wifi,
color: isWifiRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isWifiRequired,
onChanged: hasExclusiveAccess
? (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: _showErrorToUser,
onBatteryInfo: _showBatteryOptimizationInfoToUser,
)
: null,
),
if (isBackgroundEnabled)
SwitchListTile(
title: const Text("backup_controller_page_background_charging")
.tr(),
secondary: Icon(
Icons.charging_station,
color: isChargingRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isChargingRequired,
onChanged: hasExclusiveAccess
? (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: _showErrorToUser,
onBatteryInfo: _showBatteryOptimizationInfoToUser,
)
: null,
),
ElevatedButton(
onPressed: () =>
ref.read(backupProvider.notifier).configureBackgroundBackup(
enabled: !isBackgroundEnabled,
onError: _showErrorToUser,
onBatteryInfo: _showBatteryOptimizationInfoToUser,
),
child: Text(
isBackgroundEnabled
? "backup_controller_page_background_turn_off"
: "backup_controller_page_background_turn_on",
style:
const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
),
],
),
);
}
Widget _buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums;
@@ -232,33 +371,27 @@ class BackupControllerPage extends HookConsumerWidget {
children: [
const Text(
"backup_controller_page_to_backup",
style: TextStyle(color: Color(0xFF808080), fontSize: 12),
style: TextStyle(fontSize: 12),
).tr(),
_buildSelectedAlbumName(),
_buildExcludedAlbumName()
],
),
),
trailing: OutlinedButton(
style: OutlinedButton.styleFrom(
enableFeedback: true,
side: const BorderSide(
width: 1,
color: Color.fromARGB(255, 220, 220, 220),
trailing: ElevatedButton(
onPressed: hasExclusiveAccess
? () {
AutoRouter.of(context)
.push(const BackupAlbumSelectionRoute());
}
: null,
child: const Text(
"backup_controller_page_select",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
onPressed: () {
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16.0,
),
child: const Text(
"backup_controller_page_select",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
).tr(),
),
),
);
@@ -324,14 +457,14 @@ class BackupControllerPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Table(
border: TableBorder.all(
color: Colors.black12,
color: Theme.of(context).primaryColorLight,
width: 1,
),
children: [
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
decoration: const BoxDecoration(
// color: Colors.grey[100],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
@@ -355,9 +488,9 @@ class BackupControllerPage extends HookConsumerWidget {
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[200],
),
decoration: const BoxDecoration(
// color: Colors.grey[200],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
@@ -384,9 +517,9 @@ class BackupControllerPage extends HookConsumerWidget {
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
decoration: const BoxDecoration(
// color: Colors.grey[100],
),
children: [
TableCell(
child: Padding(
@@ -412,7 +545,10 @@ class BackupControllerPage extends HookConsumerWidget {
void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty();
ref.watch(backupProvider.notifier).startBackupProcess();
if (ref.watch(backupProvider).backupProgress !=
BackUpProgressEnum.inBackground) {
ref.watch(backupProvider.notifier).startBackupProcess();
}
}
return Scaffold(
@@ -445,6 +581,27 @@ class BackupControllerPage extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
).tr(),
),
hasExclusiveAccess
? const SizedBox.shrink()
: Card(
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
"Background backup is currently running, some actions are disabled",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
_buildFolderSelectionTile(),
BackupInfoCard(
title: "backup_controller_page_total".tr(),
@@ -463,7 +620,9 @@ class BackupControllerPage extends HookConsumerWidget {
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
),
const Divider(),
_buildBackupController(),
_buildAutoBackupController(),
if (Platform.isAndroid) const Divider(),
if (Platform.isAndroid) _buildBackgroundBackupController(),
const Divider(),
_buildStorageInformation(),
const Divider(),
@@ -477,9 +636,9 @@ class BackupControllerPage extends HookConsumerWidget {
backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Colors.red[300],
onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
foregroundColor: Colors.grey[50],
backgroundColor: Colors.red[300],
// padding: const EdgeInsets.all(14),
),
onPressed: () {
ref.read(backupProvider.notifier).cancelBackup();
@@ -493,11 +652,6 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(),
)
: ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
),
onPressed: shouldBackup ? startBackup : null,
child: const Text(
"backup_controller_page_start_backup",

View File

@@ -1,11 +1,9 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
import '../../../shared/providers/asset.provider.dart';
import '../providers/home_page_state.provider.dart';
class ControlBottomAppBar extends ConsumerWidget {
const ControlBottomAppBar({Key? key}) : super(key: key);
@@ -19,10 +17,10 @@ class ControlBottomAppBar extends ConsumerWidget {
height: MediaQuery.of(context).size.height * 0.15,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15),
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
color: Colors.grey[300]?.withOpacity(0.98),
color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.95),
),
child: Column(
children: [

View File

@@ -86,7 +86,6 @@ class DailyTitleText extends ConsumerWidget {
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const Spacer(),

View File

@@ -14,32 +14,22 @@ class DisableMultiSelectButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Positioned(
top: 0,
top: 10,
left: 0,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 46),
child: Material(
elevation: 20,
borderRadius: BorderRadius.circular(35),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(35),
color: Colors.grey[100],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
'$selectedItemCount',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
'$selectedItemCount',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
),

View File

@@ -7,11 +7,15 @@ import 'package:openapi/api.dart';
class ImageGrid extends ConsumerWidget {
final List<AssetResponseDto> assetGroup;
final List<AssetResponseDto> sortedAssetGroup;
final int tilesPerRow;
final bool showStorageIndicator;
ImageGrid({
Key? key,
required this.assetGroup,
required this.sortedAssetGroup,
this.tilesPerRow = 4,
this.showStorageIndicator = true,
}) : super(key: key);
List<AssetResponseDto> imageSortedList = [];
@@ -19,8 +23,8 @@ class ImageGrid extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: tilesPerRow,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
@@ -34,6 +38,7 @@ class ImageGrid extends ConsumerWidget {
ThumbnailImage(
asset: assetGroup[index],
assetList: sortedAssetGroup,
showStorageIndicator: showStorageIndicator,
),
if (assetType != AssetTypeEnum.IMAGE)
Positioned(

View File

@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
bool isEnableAutoBackup =
bool isEnableAutoBackup = backupState.backgroundBackup ||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
@@ -30,6 +30,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
floating: true,
pinned: false,
snap: false,
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)),
),
@@ -41,9 +42,10 @@ class ImmichSliverAppBar extends ConsumerWidget {
top: 5,
child: IconButton(
splashRadius: 25,
icon: const Icon(
icon: Icon(
Icons.face_outlined,
size: 30,
color: Theme.of(context).primaryColor,
),
onPressed: () {
Scaffold.of(context).openDrawer();
@@ -57,7 +59,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
child: GestureDetector(
onTap: () => Scaffold.of(context).openDrawer(),
child: Material(
color: Colors.grey[200],
// color: Colors.grey[200],
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
@@ -77,13 +79,12 @@ class ImmichSliverAppBar extends ConsumerWidget {
);
},
),
title: Text(
title: const Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
actions: [
@@ -109,17 +110,24 @@ class ImmichSliverAppBar extends ConsumerWidget {
splashRadius: 25,
iconSize: 30,
icon: isEnableAutoBackup
? const Icon(Icons.backup_rounded)
? Icon(
Icons.backup_rounded,
color: Theme.of(context).primaryColor,
)
: Badge(
padding: const EdgeInsets.all(4),
elevation: 2,
elevation: 3,
position: BadgePosition.bottomEnd(bottom: -4, end: -4),
badgeColor: Colors.white,
badgeContent: const Icon(
Icons.cloud_off_rounded,
size: 8,
color: Colors.indigo,
),
child: Icon(
Icons.backup_rounded,
color: Theme.of(context).primaryColor,
),
child: const Icon(Icons.backup_rounded),
),
onPressed: () async {
var onPop = await AutoRouter.of(context)

View File

@@ -22,7 +22,7 @@ class MonthlyTitleText extends StatelessWidget {
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
color: Theme.of(context).textTheme.headline1?.color,
),
),
),

View File

@@ -1,303 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'dart:math';
class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
AuthenticationState authState = ref.watch(authenticationProvider);
ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status;
final appInfo = useState({});
var dummmy = Random().nextInt(1024);
_getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appInfo.value = {
"version": packageInfo.version,
"buildNumber": packageInfo.buildNumber,
};
}
_buildUserProfileImage() {
if (authState.profileImagePath.isEmpty) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (authState.profileImagePath.isNotEmpty) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage(
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
),
backgroundColor: Colors.transparent,
);
} else {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
}
if (uploadProfileImageStatus == UploadProfileStatus.success) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage(
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const ImmichLoadingIndicator();
}
return const SizedBox();
}
_pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxHeight: 1024,
maxWidth: 1024,
);
if (image != null) {
var success =
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
if (success) {
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
ref.read(uploadProfileImageProvider).profileImagePath,
);
}
}
}
useEffect(
() {
_getPackageInfo();
_buildUserProfileImage();
return null;
},
[],
);
return Drawer(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color.fromARGB(255, 216, 219, 238),
Color.fromARGB(255, 226, 230, 231)
],
begin: Alignment.centerRight,
end: Alignment.centerLeft,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
clipBehavior: Clip.none,
children: [
_buildUserProfileImage(),
Positioned(
bottom: 0,
right: -5,
child: GestureDetector(
onTap: _pickUserProfileImage,
child: Material(
color: Colors.grey[50],
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(
Icons.edit,
color: Theme.of(context).primaryColor,
size: 14,
),
),
),
),
),
],
),
Text(
"${authState.firstName} ${authState.lastName}",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
Text(
authState.userEmail,
style: TextStyle(color: Colors.grey[800], fontSize: 12),
)
],
),
),
ListTile(
tileColor: Colors.grey[100],
leading: const Icon(
Icons.logout_rounded,
color: Colors.black54,
),
title: const Text(
"profile_drawer_sign_out",
style: TextStyle(
color: Colors.black54,
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(),
onTap: () async {
bool res =
await ref.watch(authenticationProvider.notifier).logout();
if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
// AutoRouter.of(context).popUntilRoot();
AutoRouter.of(context).replace(const LoginRoute());
}
},
)
],
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 0,
color: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(101, 201, 201, 201),
width: 1,
),
),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
serverInfoState.isVersionMismatch
? serverInfoState.versionMismatchErrorMessage
: "profile_drawer_client_server_up_to_date".tr(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w600,
),
),
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"App Version",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Server Version",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
),
)
],
),
);
}
}

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