Compare commits

...

160 Commits

Author SHA1 Message Date
Alex The Bot
b1d17302bc Version v1.52.1 2023-03-29 17:37:33 +00:00
Alex
cc3ffcbb84 fix(server): incorrect video file path to serve for mobile vs web (#2118) 2023-03-29 12:36:18 -05:00
Michel Heusschen
eda9e580c9 fix(server): add paused property to JobCountsDto (#2112) 2023-03-29 10:33:03 -05:00
Alex Tran
76a07a3ebc chore: add change logs 2023-03-28 17:19:10 -05:00
Alex Tran
abe87686a2 chore: post release openapi update 2023-03-28 15:49:21 -05:00
Alex Tran
6371c11fc5 Merge branch 'main' of github.com:immich-app/immich 2023-03-28 15:48:50 -05:00
Alex The Bot
d5596cf6a2 Version v1.52.0 2023-03-28 20:33:08 +00:00
Alex Tran
2f64af9cb2 Merge branch 'main' of github.com:immich-app/immich 2023-03-28 15:07:53 -05:00
Jason Rasmussen
b0d5c7035b feat(server): apply storage migration after exif completes (#2093)
* feat(server): apply storage migraiton after exif completes

* feat: same for videos

* fix: migration for live photos
2023-03-28 15:04:11 -05:00
Alex Tran
0c61521521 Merge branch 'main' of github.com:immich-app/immich 2023-03-28 14:47:51 -05:00
Jason Rasmussen
3497a0de54 chore: cleanup template variables (#2107) 2023-03-28 14:27:36 -05:00
Alex
117f2fa00d chore(ci): update prepare release action to bypass branch check (#2106)
* chore: fix api

* chore(ci): update prepare release action to bypass branch check
2023-03-28 14:26:55 -05:00
Sergey Kondrikov
2c67090e3c feat(server): add transcode presets (#2084)
* feat: add transcode presets

* Add migration

* chore: generate api

* refactor: use enum type instead of string for transcode option

* chore: generate api

* refactor: enhance readability of runVideoEncode method

* refactor: reuse SettingSelect for transcoding presets

* refactor: simplify return statement

* chore: regenerate api

* fix: correct label attribute

* Update import

* fix test

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-03-28 14:03:43 -05:00
Alex Tran
10ccbeab35 Merge branch 'main' of github.com:immich-app/immich 2023-03-28 13:45:53 -05:00
Parikshit Misra
b49f66bbc9 feat(mobile): uploading files in chunk (#2101) 2023-03-28 13:41:55 -05:00
Jason Rasmussen
9adbbd42be feat(server): resume queues (#2104)
* feat(server): resume queues

* chore: regenerate open-api
2023-03-28 13:25:22 -05:00
Jason Rasmussen
8563bd463c fix(cli): clean up set intervals (#2103) 2023-03-28 13:24:14 -05:00
Jason Rasmussen
da5a6d2272 fix(cli): missing dep in immich cli (#2094)
* fix: missing dep in immich cli

* fix: imports
2023-03-28 11:29:20 -05:00
Alex
0854737be2 feat(mobile): improve explore page and allow metadata search (#2097) 2023-03-28 15:34:06 +00:00
Alex Tran
6f3f8b0a48 Merge branch 'main' of github.com:immich-app/immich 2023-03-28 09:57:08 -05:00
Michel Heusschen
f0e272d0f2 feat(server): change clipembedding entity type (#2091) 2023-03-28 09:53:35 -05:00
Alex Tran
5e207aa7c1 Merge branch 'main' of github.com:immich-app/immich 2023-03-27 22:07:15 -05:00
Jason Rasmussen
e0b80f49b6 fix(server): increase typesense start-up settings (#2095) 2023-03-27 14:00:32 -05:00
Alex Tran
97bbe42599 Merge branch 'main' of github.com:immich-app/immich 2023-03-27 10:41:24 -05:00
Michel Heusschen
089dbdbd7e feat(server): require auth for more endpoints (#2092)
* feat(server): require auth for more endpoints

* dev: add authorization header to profile image on mobile

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-03-27 09:38:54 -05:00
Alex Tran
833c099025 Merge branch 'main' of github.com:immich-app/immich 2023-03-27 08:45:12 -05:00
Michel Heusschen
4e526dfaae feat(web): improve and refactor thumbnails (#2087)
* feat(web): improve and refactor thumbnails

* only play live photos on icon hover
2023-03-26 22:53:35 -05:00
Fynn Petersen-Frey
cae37657e9 feature(mobile): Hardening synchronization mechanism + Pull to refresh (#2085)
* fix(mobile): allow syncing duplicate local IDs

* enable to run isar unit tests on CI

* serialize sync operations, add pull to refresh on timeline

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
2023-03-26 21:35:52 -05:00
Zeyad Tamimi
1a94530935 [ Mobile ] Fixed mobile app not reporting webm MIME type (#2090) 2023-03-27 00:38:23 +00:00
Alex Tran
75d28d3c58 Merge branch 'main' of github.com:immich-app/immich 2023-03-26 10:43:52 -05:00
Alex
cd59f7aad6 fix(server) get all query does not respect asset type (#2089)
* chore: fix api

* fix(server) get all query does not respect asset type
2023-03-26 10:41:55 -05:00
Alex Tran
2f9fcd96c7 Merge branch 'main' of github.com:immich-app/immich 2023-03-25 21:51:10 -05:00
Michel Heusschen
c74fba483d feat(server): improve and refactor get all albums (#2048)
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-25 21:46:48 -05:00
Alex Tran
193dd01e06 Merge branch 'main' of github.com:immich-app/immich 2023-03-25 10:15:48 -05:00
Jason Rasmussen
2400004f41 feat(server): split generated content into a separate folder (#2047)
* feat: organize media folders

* fix: tests
2023-03-25 09:50:57 -05:00
Alex
b862c20e8e chore: fix api (#2079) 2023-03-25 04:22:02 +00:00
Alex Tran
c0ed623d26 chore: fix api 2023-03-24 23:17:40 -05:00
martyfuhry
501b96baf7 feat(mobile): Explore favorites, recently added, videos, and motion photos (#2076)
* Added placeholder for search explore

* refactor immich asset grid to use ref and provider

* all videos page

* got favorites, recently added, videos, and motion videos all using the immich grid

* Fixed issue with hero animations

* theming

* localization

* delete empty file

* style text

* Styling icons

* more styling

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-24 22:44:53 -05:00
Jonathan Jogenfors
d2600e0ddd Documentation: Add FAQ items for reverse proxy and rpi performance (#2070)
* Add FAQ items for reverse proxy and rpi performance

* Fix wording
2023-03-24 09:36:44 -05:00
Michel Heusschen
7d799b785e fix(web): remove protocol header (#2068) 2023-03-24 07:20:06 -05:00
Jason Rasmussen
e36b620020 refactor(server): cron jobs (#2067) 2023-03-24 07:19:48 -05:00
Jason Rasmussen
1efc74dabc refactor(server): common (#2066) 2023-03-23 23:55:15 -05:00
Jason Rasmussen
54f98053a8 chore(server): cleanup controllers (#2065) 2023-03-23 23:53:56 -05:00
Jason Rasmussen
6745826f35 refactor(server): media service (#2051)
* refactor(server): media service

* merge main

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-23 21:40:46 -05:00
Jason Rasmussen
bbd897b8ff refactor(server): asset serve files (#2052) 2023-03-23 21:40:30 -05:00
Alex Tran
586590e9ec fix(web): unused variables 2023-03-23 21:13:28 -05:00
Alex
4bf50a0b46 feat(web): better search bar (#2062) 2023-03-23 17:57:49 -05:00
Fynn Petersen-Frey
40832f0ea7 refactor(mobile): store backup settings on device (#2054)
Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-23 10:25:58 -05:00
martyfuhry
32a065afc7 feat(mobile): Use new search API and GridView for Places / Locations (#2043)
* Use new search API and GridView for Places / Locations

* Fixes search service by adding clip: true

* Rebased from master, uses view all explore grid now

* localized view all button

* adds empty

* style text

* Fix issue with horizontal Things not render due to missing height info

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-23 10:08:14 -05:00
Michel Heusschen
4dafc74223 fix(server): invalid video duration format (#2058) 2023-03-23 09:27:29 -05:00
bo0tzz
8adf1231a3 fix(server): Do not change file ext case on upload (#2056) 2023-03-23 09:06:40 -05:00
Michel Heusschen
c00624f209 chore: bump openapi version to v1.51.2 (#2059) 2023-03-23 06:42:33 -05:00
Fynn Petersen-Frey
eccde8fa07 refactor(mobile): migrate all Hive boxes to Isar database (#2036) 2023-03-22 20:36:44 -05:00
Skyler Mäntysaari
0616a66b05 feat(server): Allow .mkv, .wmv, .flv, .mpg videos to be uploaded. (#2045) 2023-03-22 18:34:13 -05:00
Immich Release Bot
67453d18ff Version v1.51.2 2023-03-22 21:12:45 +00:00
Michel Heusschen
792a87e407 fix(nginx): x-forwarded-* headers (#2019)
* fix(nginx): x-forwarded-* headers

* change category / add link to nginx config
2023-03-22 15:46:30 -05:00
Skyler Mäntysaari
6da50626e1 fix(server): Return the original path for gif playback (#2022)
* fix(server): Return the original path for gifs.

Usually browser is able to play them directly.

* fix(server): Better place for the condition.

* fix(server): gif viewing works properly.
2023-03-22 14:56:00 -05:00
Jason Rasmussen
6239b3b309 fix: import assets on new install (#2044) 2023-03-22 00:36:32 -05:00
Jason Rasmussen
b9bc621e2a refactor: server-info (#2038) 2023-03-21 21:49:19 -05:00
Jason Rasmussen
e10bbfa933 chore: always restart typesense (#2042) 2023-03-21 21:41:19 -05:00
Jason Rasmussen
2dd301e292 feat: show current/saved template in preset dropdown (#2040) 2023-03-21 15:19:47 -05:00
Immich Release Bot
75edc6de0f Version v1.51.1 2023-03-21 03:10:10 +00:00
Alex Tran
780c5183e3 Revert "Version v1.51.1"
This reverts commit 6e1d09fc32.
2023-03-20 22:08:47 -05:00
Jason Rasmussen
25a10784eb fix(server): search and explore part 2 (#2031)
* explore logging

* chore: regenerate open api

* fix: explore page
2023-03-20 22:07:22 -05:00
Immich Release Bot
6e1d09fc32 Version v1.51.1 2023-03-20 20:24:30 +00:00
Jason Rasmussen
73a2063d96 fix(server): search and explore issues (#2029)
* fix: send assets to typesense in batches

* fix: run classs transformer on search endpoint

* chore: log typesense filters
2023-03-20 15:16:32 -05:00
Michel Heusschen
deb1e7f41f chore: bump openapi version to v1.51.0 (#2026) 2023-03-20 11:39:00 -05:00
Alex
f45f719b9d chore: add release note for Android 2023-03-20 11:38:46 -05:00
Immich Release Bot
325639b308 Version v1.51.0 2023-03-20 16:21:28 +00:00
Jason Rasmussen
386eef046d refactor(server): jobs (#2023)
* refactor: job to domain

* chore: regenerate open api

* chore: tests

* fix: missing breaks

* fix: get asset with missing exif data

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-20 10:55:28 -05:00
Fynn Petersen-Frey
db6b14361d fix(mobile): proper syncing with Recents album on iOS (#2020)
* fix(mobile): deal with Recents album on iOS

* feature(mobile): local asset sync logging

* add comments

* delete ExifInfo when deleting Asset

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
2023-03-19 17:05:18 -05:00
Atul Mehla
719f074ccf feat(mobile): persist album sort order (#1997)
Co-authored-by: Atul Mehla <>
2023-03-19 14:54:31 -05:00
martyfuhry
646b912da8 feat(mobile): Share album name and adaptive shared album display (#2017)
* shows the owner name of shared albums

* responsive and better names

* rich text

* localization and overflow

* unused import

* adds on tap

* suppress owner name for regular album view

* aspect ratio

* Add some styling to text

* More styling

* Style album thumbnail name

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-19 19:47:51 +00:00
Michel Heusschen
b29c43d86a feat(web): bundle and 'sveltify' leaflet (#1998)
* feat(web): bundle and 'sveltify' leaflet

* lazy load leaflet components

* add correct icon sizes
2023-03-19 14:06:45 -05:00
Alex
7ce64ecf05 fix(server): CLIP search return empty result (#2018) 2023-03-19 08:20:23 -05:00
Michel Heusschen
9a332074c7 refactor(web): common layout for user pages (#1995)
* refactor(web): common layout for user pages

* remove unused imports
2023-03-18 16:31:15 -05:00
bo0tzz
dd02f1025f feat(server): Fallback to text search if machine-learning is disabled (#2015) 2023-03-18 16:30:48 -05:00
Jonathan Jogenfors
d7bfab7b13 Document cli path parameter (#2011) 2023-03-18 22:11:02 +01:00
Fynn Petersen-Frey
05cf5d57a9 feature(mobile): no longer wait for background backup in settings (#1984)
* feature(mobile): no longer wait for background backup in settings

migrate all Hive boxes required for the backup process to Isar

* add final modifier
2023-03-18 09:55:11 -05:00
Alex
f56eaae019 feat(server): CLIP search integration (#1939) 2023-03-18 08:44:42 -05:00
Alex
0d436db3ea chore(mobile): remove integration test temporarily (#2008) 2023-03-16 22:15:12 -05:00
Jonathan Jogenfors
6c8b29f326 Document fallback timezone setting (fixes #2000) (#2003)
* Update FAQ.md

* Fix typo

* Mention the extract metadata job

* Don't duplicate code
2023-03-16 12:19:35 -05:00
bo0tzz
23e76b0bd9 chore: Move away from docker hub where possible (#2006)
* chore(build): Use ghcr images in standard docker-compose

* chore(build): Use ghcr for nginx base image
2023-03-16 09:06:14 -05:00
Sergey Kondrikov
82e8cd0f8d Fix timezone mismatch in server tests (#1918) 2023-03-16 09:02:40 -05:00
Michel Heusschen
87d84b922f feat(web): improve /auth pages (#1969)
* feat(web): improve /auth pages

* invalidate load functions after login

* handle login server errors more graceful

* add loading state to oauth button
2023-03-15 16:38:29 -05:00
Fynn Petersen-Frey
04955a4123 feature(mobile): allow app to be used offline (#1932)
* feature(mobile): allow app to be used offline

* translatable server/network error message

* adjust profile drawer error message

* call getAllAsset after cold app starts

* fix analyzer error

* update asset state if length differs

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-03-15 16:29:07 -05:00
Jonathan Jogenfors
54831878e0 Fix storage template extension display (#2002)
* Display correct jpg file extension

* Fix typo in template directory

* Move storage template to correct spelling
2023-03-15 14:39:29 -05:00
martyfuhry
08ed71e51e improve login ux (#2004)
removed animated switchers to resolve issue with flutter/issues/120874
2023-03-15 14:38:26 -05:00
twitsforbrains
3a1d5de742 Document how photo dates are determined (#1978)
* Document how photo dates are determined

* missing word
2023-03-14 14:12:42 -05:00
Michel Heusschen
e15be5bf9a fix(web): short layout retention after navigation (#1994) 2023-03-14 08:56:49 -05:00
raisinbear
01afeefeb9 fix(server): remove encoded video file on asset delete (#1980)
* add check for encoded video file to be deleted with asset

* remove unnecessary code and adjust test

* complete test

* fix unit test

* fix unit test properly this time

* fix formatting

---------

Co-authored-by: Sebastian Schöttl <sebastian.schoettl@cybertechnologies.com>
2023-03-13 13:42:05 -04:00
Fynn Petersen-Frey
532bd6fe12 fix(mobile): add isar source code as a git submodule for F-Droid build (#1985) 2023-03-12 22:50:55 -05:00
Alex
416e30ede2 fix(mobile): Sorted shared album and added share user doesn't reflect change in album view (#1955)
* fix: sorted shared album

* Added TODO comment for tomorrow work

* update album shared property after adding user

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
2023-03-12 08:43:09 -05:00
Matthias Rupp
ceb81d00fc feat(web): Make scaling of albums overview more responsive (#1981)
* Make scaling of albums overview more responsive

* Adapt column sizes

* Run prettier

* Use tailwind magic instead of hard-coded breakpoints
2023-03-11 18:43:54 -06:00
martyfuhry
8adca31c24 fixes gallery viewer fullscreen edge case (#1959) 2023-03-11 06:42:35 -06:00
Fynn Petersen-Frey
3cce43309c fix(server): update album updatedAt on assets/users removed/added (#1977) 2023-03-11 06:41:46 -06:00
Dragos Rotaru
63ad802013 fix(docs): added note on scope of redis optional parameters in example.env (#1974) 2023-03-11 06:41:08 -06:00
dependabot[bot]
9313e70575 chore(deps): bump docker/setup-buildx-action from 2.4.1 to 2.5.0 (#1976) 2023-03-11 06:40:55 -06:00
bo0tzz
838ea56605 fix(server): Increase authentication cookie max-age (#1971)
This got missed in #1381.
2023-03-08 16:26:49 +00:00
Fynn Petersen-Frey
9ac087c59c fix(mobile): do not crash on malformed asset duration (#1921)
* fix(mobile): do not crash on malformed asset duration

* add unit test
2023-03-06 09:27:01 -06:00
Michel Heusschen
f52e076cb3 feat(web): improve search bar + add to search page (#1957)
* feat(web): improve search bar + add to search page

* fix back button routing
2023-03-06 08:31:58 -06:00
Michel Heusschen
8857d0b8df feat(web): require page load to export title (#1956) 2023-03-06 08:25:25 -06:00
martyfuhry
950989a85e feat(mobile): Transparent bottom Android navigation bar (#1953)
* transparent system overlay

* immersive view to gallery viewer, as well

* comments
2023-03-05 22:51:18 -06:00
martyfuhry
a4c215751e feat(mobile): Enter server first for login (#1952)
* improves login form

* login form improvements

* correctly trim server endpoint controller text when logging in

* don't show loading while fetching server info

* fixes get server login credentials

* fixes up sign in form

* error handling

* fixed layout

* removed placeholder text
2023-03-05 22:46:38 -06:00
Jason Rasmussen
2ca560ebf8 feat(web,server): explore (#1926)
* feat: explore

* chore: generate open api

* styling explore page

* styling no result page

* style overlay

* style: bluring text on thumbnail card for readability

* explore page tweaks

* fix(web): search urls

* feat(web): use objects for things

* feat(server): filter by motion, sort by createdAt

* More styling

* better navigation

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2023-03-05 14:44:31 -06:00
martyfuhry
1f631eafce fixes ios debug info tile only shown in iOS (#1951) 2023-03-05 13:50:03 -06:00
Michel Heusschen
6f605d4a35 fix(web): admin pages layout issue (#1943) 2023-03-05 08:03:51 -06:00
Alex
1918625be9 fix(web): nested layout navigation issue (#1936)
* fix(web): nested layout navigation issue

* move guarding to html template

* fix test
2023-03-04 16:09:55 -06:00
Michel Heusschen
bdf35b6688 feat(server): improve thumbnail relation and updating (#1897)
* feat(server): improve thumbnail relation and updating

* improve query + update tests and migration

* make sure uuids are valid in migration

* fix unit test
2023-03-04 08:16:48 -06:00
Michel Heusschen
2ac54ce4bd fix(web): restore album drag and drop upload (#1933) 2023-03-04 08:14:02 -06:00
Sergey Kondrikov
96d75c9ad4 Add trailing space for message in ru-RU locale (#1919) 2023-03-03 22:50:10 +00:00
martyfuhry
dac4020f27 fix(mobile): Fixes hero animation on main timeline (#1924)
* fixed hero animation for local assets

* fixes backwards hero animation out of gallery image
2023-03-03 16:49:40 -06:00
Jason Rasmussen
a5f49b065c fix: duration string parsing (#1923) 2023-03-03 16:49:22 -06:00
Olly Welch
d5d0624311 Feat/ml image optimisations (#1916)
* Use multi stage build to slim down ML image size

* Use gunicorn as WSGI server in ML image

* Configure gunicorn server for ML use case

* Use requirements.txt file to install python dependencies in ML image

* Make ML listen IP configurable

* Revert "Use requirements.txt file to install python dependencies in ML image"

This reverts commit 32e706c7f3.

* Separate out pip installs in ML builder image
2023-03-03 16:45:20 -06:00
Fynn Petersen-Frey
8708867c1c feature(mobile): sync assets, albums & users to local database on device (#1759)
* feature(mobile): sync assets, albums & users to local database on device

* try to fix tests

* move DB sync operations to new SyncService

* clear db on user logout

* fix reason for endless loading timeline

* fix error when deleting album

* fix thumbnail of device albums

* add a few comments

* fix Hive box not open in album service when loading local assets

* adjust tests to int IDs

* fix bug: show all albums when Recent is selected

* update generated api

* reworked Recents album isAll handling

* guard against wrongly interleaved sync operations

* fix: timeline asset ordering (sort asset state by created at)

* fix: sort assets in albums by created at
2023-03-03 16:38:30 -06:00
Alex
8f11529a75 chore(server): disable TypeSense logging (#1925) 2023-03-02 23:33:07 -06:00
Jason Rasmussen
0aaeab124d feat(server)!: search via typesense (#1778)
* build: add typesense to docker

* feat(server): typesense search

* feat(web): search

* fix(web): show api error response message

* chore: search tests

* chore: regenerate open api

* fix: disable typesense on e2e

* fix: number properties for open api (dart)

* fix: e2e test

* fix: change lat/lng from floats to typesense geopoint

* dev: Add smartInfo relation to findAssetById to be able to query against it

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-02 20:47:08 -06:00
Zack Pollard
1cc184ed10 Revert "feat(server): Machine learning's image optimisations (#1908)" (#1915)
This reverts commit 977740045a.
2023-03-01 11:48:35 -06:00
Michel Heusschen
830f4268c3 fix(server): exif extraction swapped params (#1914) 2023-03-01 10:55:24 -06:00
Olly Welch
977740045a feat(server): Machine learning's image optimisations (#1908)
* Use multi stage build to slim down ML image size

* Use gunicorn as WSGI server in ML image

* Configure gunicorn server for ML use case

* Use requirements.txt file to install python dependencies in ML image

* Make ML listen IP configurable
2023-03-01 09:37:12 -06:00
Michel Heusschen
2a1dcbc28b fix(server): storage template unit test (#1906) 2023-03-01 13:10:01 +00:00
Zack Pollard
21f8ab647f chore(server): bump API version post release (#1909) 2023-03-01 12:51:56 +00:00
Chipwingg
aef5a48fc6 feat(server): added additional storage template preset (#1903) 2023-02-28 23:48:55 -06:00
Immich Release Bot
434c1a0f20 Version v1.50.1 2023-03-01 04:58:47 +00:00
Alex
5fd2496774 fix(server): album sorted incorrectly (#1901) 2023-02-28 22:57:17 -06:00
Alex Tran
7411bcbb30 post release 2023-02-28 22:54:00 -06:00
Immich Release Bot
7d6d51f4a5 Version v1.50.0 2023-03-01 03:22:31 +00:00
Alex Tran
5777693fad Merge branch 'main' of github.com:immich-app/immich 2023-02-28 21:21:40 -06:00
Alex Tran
b53cc4f9db chore: test pump 2023-02-28 21:21:33 -06:00
martyfuhry
9d57039274 feat(mobile): Responsive list and grid view of backup album selection and fixes search filter (#1895)
* rebuilding gridview

* adds listview, gridview and responsive display to backup album selection

* aligned selection info title and chips to the left

* fixed search

* style: album tile

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-02-28 21:10:53 -06:00
martyfuhry
12217bde8a feat(mobile): Adds onboarding for permissions (#1865)
* adds onboarding

* fixed error where login was taking you to permission page

* fixed a bad rebase and added more checks to not start backup service on login if no gallery permission

* forgot the permission handler import in AppDelegate

* reverts album selection page

* change to ref watch

* added device_info_plus to podspec

* removed unused import

---------

Co-authored-by: Marty Fuhry <marty@fuhry.farm>
2023-02-28 10:22:18 -06:00
Alex Tran
37f802d1fe Merge branch 'main' of github.com:immich-app/immich 2023-02-28 08:58:54 -06:00
Ikko Eltociear Ashimine
df1710f4cc chore: update README.md (#1892)
Github -> GitHub
2023-02-27 22:05:22 -06:00
Alex Tran
0fec34d316 Merge branch 'main' of github.com:immich-app/immich 2023-02-27 21:02:52 -06:00
martyfuhry
a0b8312ce4 adds safe area to album to stop from clipping bottom of albums (#1889) 2023-02-27 18:35:10 -06:00
Alex Tran
8abe6909ca Merge branch 'main' of github.com:immich-app/immich 2023-02-27 18:29:02 -06:00
Alex
25cff6a748 fix(server) long album load time on Album and Sharing page (#1890)
* chore: update package-lock.json version

* rfix(server) long album load time

* remove all eagerness

* generate index

* remove console.log

* remove deadcode

* fix: shared link album owner
2023-02-27 18:28:45 -06:00
Michel Heusschen
243c98a02e feat(web): re-add version announcement (#1887)
* feat(web): re-add version announcement

* show notification for every update
2023-02-27 17:13:39 -06:00
Matthias Rupp
c9a6820de7 chore(mobile): Favorite provider unit test (#1874)
* Favorite provider tests

* Remove unused mock

* Add setUp function to avoid duplicate code
2023-02-27 21:15:25 +01:00
Alex Tran
7d586492f3 Merge branch 'main' of github.com:immich-app/immich 2023-02-26 21:23:58 -06:00
Michel Heusschen
807bdfeda9 fix(web): layout nesting (#1881)
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-02-26 21:23:43 -06:00
Alex Tran
1f25df308a Merge branch 'main' of github.com:immich-app/immich 2023-02-26 20:58:57 -06:00
Michel Heusschen
2efa8b6960 fix(web): setInterval outside onMount (#1883) 2023-02-26 15:55:06 -06:00
Alex Tran
7c9d2018d8 Merge branch 'main' of github.com:immich-app/immich 2023-02-26 15:04:10 -06:00
Olly Welch
ab90b01122 feat(server) Enable ML support for ARM CPUs (#1880)
* Install nightly release of pytorch to enable ML support for arm CPUs

* Remove linux/arm/v7 from ML docker builds

* Add --no-cache-dir to torch installation command in ML image build

* Use PIP_NO_CACHE_DIR option in ML build to further decrease image size
2023-02-26 15:00:09 -06:00
Alex Tran
d04ef319b8 Merge branch 'main' of github.com:immich-app/immich 2023-02-26 13:59:48 -06:00
Michel Heusschen
368142e79b feat(web): improved server stats (#1870)
* feat(web): improved server stats

* fix(web): don't log unauthorized errors

* Revert "fix(web): don't log unauthorized errors"

This reverts commit 7fc2987a77.
2023-02-26 13:57:34 -06:00
Alex
7d45ae68a6 chore: update package-lock.json version (#1884) 2023-02-26 13:30:50 -06:00
Alex Tran
98bedcf1e5 chore: update package-lock.json version 2023-02-26 13:24:57 -06:00
Michel Heusschen
3377fa4640 fix(web): use correct api for asset share page (#1879) 2023-02-26 13:21:59 -06:00
Michel Heusschen
641c05c6fe chore(web): update dependencies (#1877)
* chore(web): update dependencies

* remove invalid alt attribute
2023-02-26 13:18:26 -06:00
Michel Heusschen
e157a69d86 fix(web): don't log unauthorized errors (#1871)
* fix(web): don't log unauthorized errors

* fix docker build error
2023-02-26 10:50:18 -06:00
Zack Pollard
3d468c369c fix: machine learning only take results with > 90% confidence (#1875) 2023-02-25 22:02:35 -06:00
Jason Rasmussen
6c7679714b refactor(server): jobs and processors (#1787)
* refactor: jobs and processors

* refactor: storage migration processor

* fix: tests

* fix: code warning

* chore: ignore coverage from infra

* fix: sync move asset logic between job core and asset core

* refactor: move error handling inside of catch

* refactor(server): job core into dedicated service calls

* refactor: smart info

* fix: tests

* chore: smart info tests

* refactor: use asset repository

* refactor: thumbnail processor

* chore: coverage reqs
2023-02-25 08:12:03 -06:00
Alex
71d8567f18 [Localizely] Translations update (#1860) 2023-02-24 14:42:33 -06:00
Michel Heusschen
cc6253ba38 fix(web): sharing of access token in server API (#1858) 2023-02-24 14:42:20 -06:00
Alex
3ea107be5a chore Add Norweigain localization setup (#1859)
* chore(localization): Add Norwegian localization setup

* chore(localization): Add Norwegian localization setup
2023-02-24 12:50:56 -06:00
martyfuhry
4ed96cf1bd fix(mobile): Prevents duplicate taps navigating to the same route twice (#1855) 2023-02-24 10:51:35 -06:00
Michel Heusschen
9323cc76d9 feat(server): improve API specification (#1853) 2023-02-24 10:01:10 -06:00
Alex
da9b9c8c69 chore: post release tasks (#1849) 2023-02-23 15:16:16 -06:00
601 changed files with 33896 additions and 11471 deletions

View File

@@ -29,7 +29,7 @@ jobs:
platforms: "linux/arm/v7,linux/amd64,linux/arm64"
- context: "machine-learning"
image: "immich-machine-learning"
platforms: "linux/amd64"
platforms: "linux/amd64,linux/arm64"
- context: "nginx"
image: "immich-proxy"
platforms: "linux/arm/v7,linux/amd64,linux/arm64"
@@ -42,7 +42,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.4.1
uses: docker/setup-buildx-action@v2.5.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761

View File

@@ -41,8 +41,9 @@ jobs:
id: push-tag
uses: EndBug/add-and-commit@v9
with:
author_name: Immich Release Bot
author_email: bot@immich.app
author_name: Alex The Bot
author_email: alex.tran1502@gmail.com
default_author: user_info
message: "Version ${{ env.IMMICH_VERSION }}"
tag: ${{ env.IMMICH_VERSION }}
push: true

View File

@@ -52,8 +52,8 @@ jobs:
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.7.3'
channel: "stable"
flutter-version: "3.7.3"
- name: Run tests
working-directory: ./mobile
run: flutter test
@@ -124,77 +124,77 @@ jobs:
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
exit 1
mobile-integration-tests:
name: Run mobile end-to-end integration tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '12.x'
cache: 'gradle'
- name: Cache android SDK
uses: actions/cache@v3
id: android-sdk
with:
key: android-sdk
path: |
/usr/local/lib/android/
~/.android
- name: Cache Gradle
uses: actions/cache@v3
with:
path: |
./mobile/build/
./mobile/android/.gradle/
key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
- name: Setup Android SDK
if: steps.android-sdk.outputs.cache-hit != 'true'
uses: android-actions/setup-android@v2
- name: AVD cache
uses: actions/cache@v3
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-29
- name: create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2.27.0
with:
working-directory: ./mobile
cores: 2
api-level: 29
arch: x86_64
profile: pixel
target: default
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.7.3'
cache: true
- name: Run integration tests
uses: Wandalen/wretry.action@master
with:
action: reactivecircus/android-emulator-runner@v2.27.0
with: |
working-directory: ./mobile
cores: 2
api-level: 29
arch: x86_64
profile: pixel
target: default
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: |
flutter pub get
flutter test integration_test
attempt_limit: 3
# mobile-integration-tests:
# name: Run mobile end-to-end integration tests
# runs-on: macos-latest
# steps:
# - uses: actions/checkout@v3
# - uses: actions/setup-java@v3
# with:
# distribution: 'zulu'
# java-version: '12.x'
# cache: 'gradle'
# - name: Cache android SDK
# uses: actions/cache@v3
# id: android-sdk
# with:
# key: android-sdk
# path: |
# /usr/local/lib/android/
# ~/.android
# - name: Cache Gradle
# uses: actions/cache@v3
# with:
# path: |
# ./mobile/build/
# ./mobile/android/.gradle/
# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
# - name: Setup Android SDK
# if: steps.android-sdk.outputs.cache-hit != 'true'
# uses: android-actions/setup-android@v2
# - name: AVD cache
# uses: actions/cache@v3
# id: avd-cache
# with:
# path: |
# ~/.android/avd/*
# ~/.android/adb*
# key: avd-29
# - name: create AVD and generate snapshot for caching
# if: steps.avd-cache.outputs.cache-hit != 'true'
# uses: reactivecircus/android-emulator-runner@v2.27.0
# with:
# working-directory: ./mobile
# cores: 2
# api-level: 29
# arch: x86_64
# profile: pixel
# target: default
# force-avd-creation: false
# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
# disable-animations: false
# script: echo "Generated AVD snapshot for caching."
# - name: Setup Flutter SDK
# uses: subosito/flutter-action@v2
# with:
# channel: 'stable'
# flutter-version: '3.7.3'
# cache: true
# - name: Run integration tests
# uses: Wandalen/wretry.action@master
# with:
# action: reactivecircus/android-emulator-runner@v2.27.0
# with: |
# working-directory: ./mobile
# cores: 2
# api-level: 29
# arch: x86_64
# profile: pixel
# target: default
# force-avd-creation: false
# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
# disable-animations: true
# script: |
# flutter pub get
# flutter test integration_test
# attempt_limit: 3

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "mobile/.isar"]
path = mobile/.isar
url = https://github.com/isar/isar

View File

@@ -1,17 +0,0 @@
# Deployment checklist for iOS/Android/Server
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
All of the version should be the same.

View File

@@ -92,7 +92,7 @@ If you feel like this is the right cause and the app is something you are seeing
## Donation
- [Monthly donation](https://github.com/sponsors/alextran1502) via GitHub Sponsors
- [One-time donation](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via Github Sponsors
- [One-time donation](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX

View File

@@ -17,3 +17,5 @@ ENABLE_MAPBOX=false
# WEB
MAPBOX_KEY=
VITE_SERVER_ENDPOINT=http://localhost:2283/api
TYPESENSE_ENABLED=false

View File

@@ -23,6 +23,7 @@ services:
depends_on:
- redis
- database
- typesense
immich-machine-learning:
container_name: immich_machine_learning
@@ -64,6 +65,7 @@ services:
depends_on:
- database
- immich-server
- typesense
immich-web:
container_name: immich_web
@@ -89,6 +91,17 @@ services:
depends_on:
- immich-server
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.0
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
logging:
driver: none
volumes:
- tsdata:/data
redis:
container_name: immich_redis
image: redis:6.2
@@ -129,3 +142,4 @@ services:
volumes:
pgdata:
model-cache:
tsdata:

View File

@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
immich-server-test:
@@ -9,7 +9,7 @@ services:
target: builder
command: npm run test:e2e
expose:
- '3000'
- "3000"
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
@@ -17,6 +17,7 @@ services:
- .env.test
environment:
- NODE_ENV=development
- TYPESENSE_ENABLED=false
depends_on:
- immich-redis-test
- immich-database-test

View File

@@ -3,8 +3,8 @@ version: "3.8"
services:
immich-server:
container_name: immich_server
image: altran1502/immich-server:release
entrypoint: [ "/bin/sh", "./start-server.sh" ]
image: ghcr.io/immich-app/immich-server:release
entrypoint: ["/bin/sh", "./start-server.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -14,12 +14,13 @@ services:
depends_on:
- redis
- database
- typesense
restart: always
immich-microservices:
container_name: immich_microservices
image: altran1502/immich-server:release
entrypoint: [ "/bin/sh", "./start-microservices.sh" ]
image: ghcr.io/immich-app/immich-server:release
entrypoint: ["/bin/sh", "./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -29,11 +30,12 @@ services:
depends_on:
- redis
- database
- typesense
restart: always
immich-machine-learning:
container_name: immich_machine_learning
image: altran1502/immich-machine-learning:release
image: ghcr.io/immich-app/immich-machine-learning:release
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- model-cache:/cache
@@ -45,12 +47,24 @@ services:
immich-web:
container_name: immich_web
image: altran1502/immich-web:release
entrypoint: [ "/bin/sh", "./entrypoint.sh" ]
image: ghcr.io/immich-app/immich-web:release
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
restart: always
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.0
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
logging:
driver: none
volumes:
- tsdata:/data
restart: always
redis:
container_name: immich_redis
image: redis:6.2
@@ -72,7 +86,7 @@ services:
immich-proxy:
container_name: immich_proxy
image: altran1502/immich-proxy:release
image: ghcr.io/immich-app/immich-proxy:release
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
@@ -88,3 +102,4 @@ services:
volumes:
pgdata:
model-cache:
tsdata:

View File

@@ -17,6 +17,11 @@ DB_DATABASE_NAME=immich
REDIS_HOSTNAME=immich_redis
# Optional Redis settings:
# Note: these parameters are not automatically passed to the Redis Container
# to do so, please edit the docker-compose.yml file as well. Redis is not configured
# via environment variables, only redis.conf or the command line
# REDIS_PORT=6379
# REDIS_DBINDEX=0
# REDIS_PASSWORD=
@@ -30,6 +35,13 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Typesense
###################################################################################
TYPESENSE_API_KEY=some-random-text
# TYPESENSE_ENABLED=false
###################################################################################
# Reverse Geocoding
#
@@ -76,4 +88,4 @@ IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
# Examples: http://localhost:3001, http://immich-api.example.com, etc
####################################################################################
#IMMICH_API_URL_EXTERNAL=http://localhost:3001
#IMMICH_API_URL_EXTERNAL=http://localhost:3001

View File

@@ -14,16 +14,38 @@ sidebar_position: 7
### How can I sync an existing directory with Immich's server?
Immich doesn't have the mechanism to sync an existing directory with the server. There is however, a helper CLI tool to help you bulk upload the existing photos and videos to the server. You can find the guide to use the CLI tool [here](/docs/features/bulk-upload.md).
Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app/immich/discussions/1006)), but the [command line tool](/docs/features/bulk-upload.md) can bulk upload items from a directory to Immich.
### Why doesn't Immich watch an existing photo gallery directory?
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
### Why does my uploaded photo show up with the wrong date or time in Immich?
When a photo is initially uploaded Immich uses the create date of the file to determine where it belongs in the timeline. After that, background jobs will run that extract [exif metadata](https://en.wikipedia.org/wiki/Exif), including the CreateDate, to provide a more accurate date for the photo. If that is not available it will fallback to the modified date. If you want to ensure your photo has the right date, check the exif metadata before uploading.
If the timezone is incorrect in an uploaded photo, check the ``DateTimeOriginal`` exif field of the uploaded file. Immich uses the very competent library [exiftool-vendored.js](https://github.com/photostructure/exiftool-vendored.js#dates) to handle timezone parsing, but in some cases (like photos taken with DSLR cameras) it has to fallback on the local timezone. If you are using docker, this fallback will be UTC. (Note that even the photo backup app that can't be named [has the same bug!](https://photo.stackexchange.com/a/126978)) In Immich, it is possible to change this assumed fallback timezone system-wide by setting the timezone in the microservices docker container. You might need to run the "Extract Metadata" job after to effect the change.
As an example, the following modification of ```docker-compose.yml``` will set the timezone of the microservices container to be ``Europe/Stockholm``
```
environment:
- TZ=Europe/Stockholm # <---- Add this line in the microservices config
```
### Why are only photos and not videos being uploaded to Immich?
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes.
### Why is Immich slow on low-memory systems like the Raspberry Pi?
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file.
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
### In the uploads folder, why are photos stored in the wrong date?
This is fixed by running the storage migration job.
### Why is object detection not very good?
The model we used for machine learning is a prebuilt model, so the accuracy is not very good. It will hopefully be replaced with a better solution in the future.

View File

@@ -0,0 +1,22 @@
# Reverse Proxy
When deploying Immich it is important to understand that a reverse proxy is required in front of the server and web container. The reverse proxy acts as an intermediary between the user and container, forwarding requests to the correct container based on the URL path.
## Default Reverse Proxy
Immich provides a default nginx reverse proxy preconfigured to perform the correct routing and set the necessary headers for the server and web container to use. These headers are crucial to redirect to the correct URL and determine the client's IP address.
## Using a Different Reverse Proxy
While the reverse proxy provided by Immich works well for basic deployments, some users may want to use a different reverse proxy. Fortunately, Immich is flexible enough to accommodate different reverse proxies. Users can either:
1. Add another reverse proxy on top of Immich's reverse proxy
2. Completely replace the default reverse proxy
## Adding a Custom Reverse Proxy
Users can deploy a custom reverse proxy that forwards requests to Immich's reverse proxy. This way, the new reverse proxy can handle TLS termination, load balancing, or other advanced features, while still delegating routing decisions to Immich's reverse proxy. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
## Replacing the Default Reverse Proxy
Replacing Immich's default reverse proxy is an advanced deployment and support may be limited. When replacing Immich's default proxy it is important to ensure that requests to `/api/*` are routed to the server container and all other requests to the web container. Additionally, the previously mentioned headers should be configured accordingly. You may find our [nginx configuration file](https://github.com/immich-app/immich/blob/main/nginx/templates/default.conf.template) a helpful reference.

View File

@@ -17,23 +17,29 @@ npm i -g immich
## Quick Start
Specify user's credentials, Immich's server address and port, and the directory you would like to upload videos/photos from.
Specify user's credential, Immich's server address and port and the directory you would like to upload videos/photos from.
```bash
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api -d your/target/directory
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg
```
By default, subfolders are not included. To upload a directory including subfolder, use the --recursive option:
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/
```
---
### Parameters
### Options
| Parameter | Description |
| ---------------- | ------------------------------------------------------------------- |
| --yes / -y | Assume yes on all interactive prompts |
| --recursive / -r | Include subfolders |
| --delete / -da | Delete local assets after upload |
| --key / -k | User's API key |
| --server / -s | Immich's server address |
| --directory / -d | Directory to upload from |
| --threads / -t | Number of threads to use (Default 5) |
| --album/ -al | Create albums for assets based on the parent folder or a given name |
@@ -92,5 +98,5 @@ npm run build
```
```bash title="Run the command"
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api -d your/target/directory
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
```

View File

@@ -46,6 +46,11 @@ DB_DATABASE_NAME=immich
REDIS_HOSTNAME=immich_redis
# Optional Redis settings:
# Note: these parameters are not automatically passed to the Redis Container
# to do so, please edit the docker-compose.yml file as well. Redis is not configured
# via environment variables, only redis.conf or the command line
# REDIS_PORT=6379
# REDIS_DBINDEX=0
# REDIS_PASSWORD=

View File

@@ -44,3 +44,5 @@ download:
locale_code: ru-RU
- file: mobile/assets/i18n/cs-CZ.json
locale_code: cs-CZ
- file: mobile/assets/i18n/no-NO.json
locale_code: no-NO

View File

@@ -1,18 +1,25 @@
FROM python:3.10
FROM python:3.10 as builder
ENV TRANSFORMERS_CACHE=/cache
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=true
RUN python -m venv /opt/venv
RUN /opt/venv/bin/pip install --pre torch -f https://download.pytorch.org/whl/nightly/cpu/torch_nightly.html
RUN /opt/venv/bin/pip install transformers tqdm numpy scikit-learn scipy nltk sentencepiece flask Pillow gunicorn
RUN /opt/venv/bin/pip install --no-deps sentence-transformers
FROM python:3.10-slim
COPY --from=builder /opt/venv /opt/venv
ENV TRANSFORMERS_CACHE=/cache \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /usr/src/app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir torch==1.13.1+cpu -f https://download.pytorch.org/whl/torch_stable.html
RUN pip install transformers tqdm numpy scikit-learn scipy nltk sentencepiece flask Pillow
RUN pip install --no-deps sentence-transformers
COPY . .
CMD ["python", "src/main.py"]
CMD ["gunicorn", "src.main:server"]

View File

@@ -0,0 +1,29 @@
"""
Gunicorn configuration options.
https://docs.gunicorn.org/en/stable/settings.html
"""
import os
# Set the bind address based on the env
port = os.getenv("MACHINE_LEARNING_PORT") or "3003"
listen_ip = os.getenv("MACHINE_LEARNING_IP") or "0.0.0.0"
bind = [f"{listen_ip}:{port}"]
# Preload the Flask app / models etc. before starting the server
preload_app = True
# Logging settings - log to stdout and set log level
accesslog = "-"
loglevel = os.getenv("MACHINE_LEARNING_LOG_LEVEL") or "info"
# Worker settings
# ----------------------
# It is important these are chosen carefully as per
# https://pythonspeed.com/articles/gunicorn-in-docker/
# Otherwise we get workers failing to respond to heartbeat checks,
# especially as requests take a long time to complete.
workers = 2
threads = 4
worker_tmp_dir = "/dev/shm"
timeout = 60

View File

@@ -1,43 +1,58 @@
import os
from flask import Flask, request
from transformers import pipeline
from sentence_transformers import SentenceTransformer, util
from PIL import Image
is_dev = os.getenv('NODE_ENV') == 'development'
server_port = os.getenv('MACHINE_LEARNING_PORT', 3003)
server_host = os.getenv('MACHINE_LEARNING_HOST', '0.0.0.0')
classification_model = os.getenv('MACHINE_LEARNING_CLASSIFICATION_MODEL', 'microsoft/resnet-50')
object_model = os.getenv('MACHINE_LEARNING_OBJECT_MODEL', 'hustvl/yolos-tiny')
clip_image_model = os.getenv('MACHINE_LEARNING_CLIP_IMAGE_MODEL', 'clip-ViT-B-32')
clip_text_model = os.getenv('MACHINE_LEARNING_CLIP_TEXT_MODEL', 'clip-ViT-B-32')
_model_cache = {}
def _get_model(model, task=None):
global _model_cache
key = '|'.join([model, str(task)])
if key not in _model_cache:
if task:
_model_cache[key] = pipeline(model=model, task=task)
else:
_model_cache[key] = SentenceTransformer(model)
return _model_cache[key]
server = Flask(__name__)
classifier = pipeline(
task="image-classification",
model="microsoft/resnet-50"
)
detector = pipeline(
task="object-detection",
model="hustvl/yolos-tiny"
)
# Environment resolver
is_dev = os.getenv('NODE_ENV') == 'development'
server_port = os.getenv('MACHINE_LEARNING_PORT') or 3003
@server.route("/ping")
def ping():
return "pong"
@server.route("/object-detection/detect-object", methods=['POST'])
def object_detection():
model = _get_model(object_model, 'object-detection')
assetPath = request.json['thumbnailPath']
return run_engine(detector, assetPath), 201
return run_engine(model, assetPath), 200
@server.route("/image-classifier/tag-image", methods=['POST'])
def image_classification():
model = _get_model(classification_model, 'image-classification')
assetPath = request.json['thumbnailPath']
return run_engine(classifier, assetPath), 201
return run_engine(model, assetPath), 200
@server.route("/sentence-transformer/encode-image", methods=['POST'])
def clip_encode_image():
model = _get_model(clip_image_model)
assetPath = request.json['thumbnailPath']
return model.encode(Image.open(assetPath)).tolist(), 200
@server.route("/sentence-transformer/encode-text", methods=['POST'])
def clip_encode_text():
model = _get_model(clip_text_model)
text = request.json['text']
return model.encode(text).tolist(), 200
def run_engine(engine, path):
result = []
@@ -45,11 +60,8 @@ def run_engine(engine, path):
for index, pred in enumerate(predictions):
tags = pred['label'].split(', ')
if (index == 0):
result = tags
else:
if (pred['score'] > 0.5):
result = [*result, *tags]
if (pred['score'] > 0.9):
result = [*result, *tags]
if (len(result) > 1):
result = list(set(result))
@@ -58,4 +70,4 @@ def run_engine(engine, path):
if __name__ == "__main__":
server.run(debug=is_dev, host='0.0.0.0', port=server_port)
server.run(debug=is_dev, host=server_host, port=server_port)

View File

@@ -76,10 +76,11 @@ sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.in
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
# OpenApi Generated Files
sed -i "s/\"version\": \"$CURRENT_SERVER\",$/\"version\": \"$NEXT_SERVER\",/" mobile/openapi/README.md
sed -i "s/\"document\": \"$CURRENT_SERVER\",$/\"document\": \"$NEXT_SERVER\",/" web/src/api/open-api/api.ts
sed -i "s/\"document\": \"$CURRENT_SERVER\",$/\"document\": \"$NEXT_SERVER\",/" web/src/api/open-api/base.ts
sed -i "s/\"document\": \"$CURRENT_SERVER\",$/\"document\": \"$NEXT_SERVER\",/" web/src/api/open-api/common.ts
sed -i "s/\"document\": \"$CURRENT_SERVER\",$/\"document\": \"$NEXT_SERVER\",/" web/src/api/open-api/configuration.ts
sed -i "s/\"document\": \"$CURRENT_SERVER\",$/\"document\": \"$NEXT_SERVER\",/" web/src/api/open-api/index.ts
sed -i "s/API version: $CURRENT_SERVER,$/API version: $NEXT_SERVER/" mobile/openapi/README.md
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/api.ts
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/base.ts
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/common.ts
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/configuration.ts
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/index.ts
echo "IMMICH_VERSION=v$NEXT_SERVER" >>$GITHUB_ENV

1
mobile/.isar Submodule

Submodule mobile/.isar added at 70da4e0bbd

View File

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

View File

@@ -0,0 +1,2 @@
* Fix no album thumbnail lead to no album selection shown and add global logs
* Fix remove asset from gallery view

View File

@@ -0,0 +1,4 @@
* fix: Prevents duplicate taps navigating to the same route twice.
* fix: Adds safe area to album to stop from clipping bottom of albums.
* feat: Adds onboarding for permissions.
* feat: Responsive list and grid view of backup album selection and fixes search filter.

View File

@@ -0,0 +1,12 @@
* Enter server first for login
* Sync assets, albums & users to local database on device
* Fixes hero animation on main timeline by
* Transparent bottom Android navigation bar
* Fix do not crash on malformed asset duration
* Gallery viewer fullscreen edge case
* Fix Sorted shared album and added share user doesn't reflect change in album view
* Allow app to be used offline
* No longer wait for background backup in settings
* Share album name and adaptive shared album display
* Persist album sort order

View File

@@ -0,0 +1,8 @@
* refactor: migrate all Hive boxes to Isar database.
* feat: Use new search API and GridView for Places / Locations.
* refactor: store backup settings on device.
* feat: Explore favorites, recently added, videos, and motion photos.
* fix: Fixed mobile app not reporting webm MIME type.
* feature: Hardening synchronization mechanism + Pull to refresh.
* feat: improve explore page and allow metadata search.
* feat: Allow headers to upload large file in chunk.

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000284">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000285">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="282.422814">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="168.230955">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="43.555992">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="43.975952">
</testcase>

View File

@@ -1,6 +1,6 @@
{
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_album_bottom_sheet_added": "Přidáno do {album}",
"add_to_album_bottom_sheet_already_exists": "Již v {album}",
"album_info_card_backup_album_excluded": "VYLOUČENO",
"album_info_card_backup_album_included": "ZAHRNUTO",
"album_thumbnail_card_item": "1 položka",
@@ -14,10 +14,10 @@
"album_viewer_appbar_share_leave": "Opustit album",
"album_viewer_appbar_share_remove": "Odstranit z alba",
"album_viewer_page_share_add_users": "Přidat uživatele",
"asset_list_layout_settings_dynamic_layout_title": "Dynamic layout",
"asset_list_layout_settings_group_by": "Group assets by",
"asset_list_layout_settings_group_by_month": "Month",
"asset_list_layout_settings_group_by_month_day": "Month + day",
"asset_list_layout_settings_dynamic_layout_title": "Dynamické rozložení",
"asset_list_layout_settings_group_by": "Seskupit položky podle",
"asset_list_layout_settings_group_by_month": "Měsíc",
"asset_list_layout_settings_group_by_month_day": "Měsíc + den",
"asset_list_settings_subtitle": "Nastavení rozložení mřížky fotografií",
"asset_list_settings_title": "Fotografická mřížka",
"backup_album_selection_page_albums_device": "Alba v zařízení ({})",
@@ -27,21 +27,24 @@
"backup_album_selection_page_selection_info": "Informace o výběru",
"backup_album_selection_page_total_assets": "Celkový počet jedinečných souborů",
"backup_all": "Vše",
"backup_background_service_backup_failed_message": "Zálohování zdrojů selhalo. Zkouším to znovu...",
"backup_background_service_backup_failed_message": "Zálohování médií selhalo. Zkouším to znovu...",
"backup_background_service_connection_failed_message": "Nepodařilo se připojit k serveru. Zkouším to znovu...",
"backup_background_service_current_upload_notification": "Nahrávání {}",
"backup_background_service_default_notification": "Kontrola nových zdrojů {}",
"backup_background_service_default_notification": "Kontrola nových médií {}",
"backup_background_service_error_title": "Chyba zálohování",
"backup_background_service_in_progress_notification": "Vytvářím kopii vašich zdrojů...",
"backup_background_service_in_progress_notification": "Vytvářím kopii vašich médií...",
"backup_background_service_upload_failure_notification": "Nepodařilo se nahrát {}",
"backup_controller_page_albums": "Zálohovaná alba",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Ukaž mi jak",
"backup_controller_page_background_battery_info_message": "Chcete-li dosáhnout nejlepších výsledků při zálohování na pozadí, vypněte všechny optimalizace baterie, které omezují aktivitu na pozadí pro Immich ve vašem zařízení. Jelikož to závisí na zařízení, zkontrolujte požadované informace pro výrobce vašeho zařízení.",
"backup_controller_page_background_battery_info_ok": "OK",
"backup_controller_page_background_battery_info_title": "Optimalizace baterie",
"backup_controller_page_background_charging": "Pouze během nabíjení",
"backup_controller_page_background_configure_error": "Nepodařilo se nakonfigurovat službu na pozadí",
"backup_controller_page_background_delay": "Zpoždění zálohování nových zdrojů: {}",
"backup_controller_page_background_delay": "Zpoždění zálohování nových médií: {}",
"backup_controller_page_background_description": "Povolte službu na pozadí pro automatické zálohování všech nových aktiv bez nutnosti otevření aplikace",
"backup_controller_page_background_is_off": "Automatické zálohování na pozadí je vypnuto",
"backup_controller_page_background_is_on": "Automatické zálohování na pozadí je zapnuto",
@@ -89,21 +92,21 @@
"cache_settings_subtitle": "Ovládání chování mobilní aplikace Immich v mezipaměti",
"cache_settings_thumbnail_size": "Velikost vyrovnávací paměti náhledů (položek {})",
"cache_settings_title": "Nastavení vyrovnávací paměti",
"change_password_form_confirm_password": "Confirm Password",
"change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
"change_password_form_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"common_add_to_album": "Add to album",
"common_change_password": "Change Password",
"common_create_new_album": "Create new album",
"common_shared": "Shared",
"change_password_form_confirm_password": "Potvrďte heslo",
"change_password_form_description": "Dobrý den, {firstName} {lastName},\n\nJe to buď poprvé, co se přihlašujete do systému, nebo byl podán požadavek na změnu hesla. Níže prosím zadejte nové heslo.",
"change_password_form_new_password": "Nové heslo",
"change_password_form_password_mismatch": "Hesla se neshodují",
"change_password_form_reenter_new_password": "Znovu zadejte nové heslo",
"common_add_to_album": "Přidat do alba",
"common_change_password": "Změnit heslo",
"common_create_new_album": "Vytvořit nové album",
"common_shared": "Sdílené",
"control_bottom_app_bar_add_to_album": "Přidat do alba",
"control_bottom_app_bar_album_info": "{} položky",
"control_bottom_app_bar_album_info_shared": "{} položky - sdílené",
"control_bottom_app_bar_create_new_album": "Vytvořit nové album",
"control_bottom_app_bar_delete": "Vymazat",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_favorite": "Oblíbené",
"control_bottom_app_bar_share": "Sdílet",
"create_album_page_untitled": "Bez názvu",
"create_shared_album_page_create": "Vytvořit",
@@ -126,13 +129,13 @@
"experimental_settings_title": "Experimentální",
"favorites_page_title": "Oblíbené",
"home_page_add_to_album_conflicts": "Přidány {added} položky do alba {album}. {failed} položky jsou již v albu.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_err_local": "Zatím není možné přidat lokální média do alb, přeskakuje se",
"home_page_add_to_album_success": "Přidány položky {added} do alba {album}.",
"home_page_building_timeline": "Vytváraní časové osy",
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_favorite_err_local": "Zatím není možné zařadit lokální média mezi oblíbená, přeskakuje se",
"home_page_first_time_notice": "Pokud aplikaci používáte poprvé, nezapomeňte si vybrat zálohovaná alba, aby se na časové ose mohly nacházet fotografie a videa z vybraných albech.",
"image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_success": "Download Success",
"image_viewer_page_state_provider_download_error": "Chyba stahování",
"image_viewer_page_state_provider_download_success": "Stahování bylo úspěšné",
"library_page_albums": "Alba",
"library_page_favorites": "Oblíbené",
"library_page_new_album": "Nové album",
@@ -156,12 +159,12 @@
"login_form_password_hint": "heslo",
"login_form_save_login": "Zůstat přihlášen",
"monthly_title_text_date_format": "LLLL y",
"notification_permission_dialog_cancel": "Cancel",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_dialog_settings": "Settings",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
"notification_permission_list_tile_title": "Notification Permission",
"notification_permission_dialog_cancel": "Zrušit",
"notification_permission_dialog_content": "Chcete-li povolit oznámení, přejděte do Nastavení a vyberte možnost Povolit.",
"notification_permission_dialog_settings": "Nastavení",
"notification_permission_list_tile_content": "Udělte oprávnění k aktivaci oznámení.",
"notification_permission_list_tile_enable_button": "Povolit oznámení",
"notification_permission_list_tile_title": "Povolení oznámení",
"profile_drawer_app_logs": "Logy",
"profile_drawer_client_server_up_to_date": "Klient a server jsou aktuální",
"profile_drawer_settings": "Nastavení",
@@ -175,8 +178,8 @@
"select_additional_user_for_sharing_page_suggestions": "Návrhy",
"select_user_for_sharing_page_err_album": "Nepodařilo se vytvořit album",
"select_user_for_sharing_page_share_suggestions": "Návrhy",
"server_info_box_app_version": "App Version",
"server_info_box_server_version": "Server Version",
"server_info_box_app_version": "Verze aplikace",
"server_info_box_server_version": "Verze serveru",
"setting_image_viewer_help": "V prohlížeči detailů se nejprve načte malá miniatura, poté se načte náhled střední velikosti (je-li povolen) a nakonec se načte originál (je-li povolen).",
"setting_image_viewer_original_subtitle": "Umožňuje načíst původní obrázek v plném rozlišení (velký!). Zakázat pro snížení používání dat (v síti iv mezipaměti zařízení).",
"setting_image_viewer_original_title": "Načíst původní obrázek",

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Tager backup af dine elementer...",
"backup_background_service_upload_failure_notification": "Failed to upload {}",
"backup_controller_page_albums": "Sikkerhedskopiér albummer",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Vis mig hvordan",
"backup_controller_page_background_battery_info_message": "For den bedste oplevelse med baggrundsbackup, bør du slå batterioptimering, der begrænder baggrundsaktivitet, fra.\n\nSiden dette er afhængigt af enheden, bør du undersøge denne information leveret af din enheds producent.",
"backup_controller_page_background_battery_info_ok": "OK",

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Backing up your assets…",
"backup_background_service_upload_failure_notification": "Failed to upload {}",
"backup_controller_page_albums": "Gesicherte Alben",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"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",

View File

@@ -35,6 +35,9 @@
"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_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"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",
@@ -98,6 +101,7 @@
"common_change_password": "Change Password",
"common_create_new_album": "Create new album",
"common_shared": "Shared",
"common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.",
"control_bottom_app_bar_add_to_album": "Add to album",
"control_bottom_app_bar_album_info": "{} items",
"control_bottom_app_bar_album_info_shared": "{} items · Shared",
@@ -139,6 +143,7 @@
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"library_page_device_albums": "Albums on Device",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
@@ -153,8 +158,11 @@
"login_form_failed_login": "Error logging you in, check server URL, email and password",
"login_form_label_email": "Email",
"login_form_label_password": "Password",
"login_form_password_hint": "password",
"login_form_password_hint": "Password",
"login_form_save_login": "Stay logged in",
"login_form_server_empty": "Enter a server URL.",
"login_form_server_error": "Could not connect to server.",
"login_form_api_exception": "API exception. Please check the server URL and try again.",
"monthly_title_text_date_format": "MMMM y",
"notification_permission_dialog_cancel": "Cancel",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
@@ -227,7 +235,32 @@
"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",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings"
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
"permission_onboarding_grant_permission": "Grant permission",
"permission_onboarding_permission_granted": "Permission granted! You are all set.",
"permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.",
"permission_onboarding_get_started": "Get started",
"permission_onboarding_go_to_settings": "Go to settings",
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
"permission_onboarding_continue_anyway": "Continue anyway",
"permission_onboarding_log_out": "Log out",
"login_form_next_button": "Next",
"album_thumbnail_shared_by": "Shared by {}",
"album_thumbnail_owned": "Owned",
"curated_object_page_title": "Things",
"curated_location_page_title": "Places",
"search_page_view_all_button": "View all",
"search_page_your_activity": "Your activity",
"search_page_favorites": "Favorites",
"search_page_videos": "Videos",
"all_videos_page_title": "Videos",
"recently_added_page_title": "Recently Added",
"motion_photos_page_title": "Motion Photos",
"search_page_motion_photos": "Motion Photos",
"search_page_recently_added": "Recently added",
"search_page_categories": "Categories",
"search_page_screenshots": "Screenshots",
"search_page_selfies": "Selfies",
"search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ",
"search_suggestion_list_smart_search_hint_2": "m:your-search-term"
}

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Backing up your assets…",
"backup_background_service_upload_failure_notification": "Failed to upload {}",
"backup_controller_page_albums": "Álbumes de copia de seguridad",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"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",

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Varmuuskopioidaan kohteita...",
"backup_background_service_upload_failure_notification": "Lähetys palvelimelle epäonnistui {}",
"backup_controller_page_albums": "Varmuuskopioi albumit",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Näytä minulle miten",
"backup_controller_page_background_battery_info_message": "Kytke pois päältä kaikki Immichin taustatyöskentelyyn liittyvät akun optimoinnit, jotta varmistat taustavarmuuskopioinnin parhaan mahdollisen toiminnan.\n\nKoska tämä on laitekohtaista, tarkista tarvittavat toimet laitevalmistajan ohjeista.",
"backup_controller_page_background_battery_info_ok": "OK",

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Sauvegarde de vos éléments...",
"backup_background_service_upload_failure_notification": "Impossible de transférer {}",
"backup_controller_page_albums": "Sauvegarder les albums",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Montrez-moi comment",
"backup_controller_page_background_battery_info_message": "Pour une expérience optimale de la sauvegarde en arrière-plan, veuillez désactiver toute optimisation de la batterie limitant l'activité en arrière-plan pour Immich.\n\nÉtant donné que cela est spécifique à chaque appareil, veuillez consulter les informations requises pour le fabricant de votre appareil.",
"backup_controller_page_background_battery_info_ok": "OK",

View File

@@ -35,6 +35,9 @@
"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_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Mostrami come",
"backup_controller_page_background_battery_info_message": "Per una migliore esperienza di backup, disabilita le ottimizzazioni 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",

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "バックアップ中",
"backup_background_service_upload_failure_notification": "{} のアップロードに失敗",
"backup_controller_page_albums": "アルバム",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "方法を見る",
"backup_controller_page_background_battery_info_message": "バックグラウンドバックアップが正常に動作するためにImmichに適用されてるバッテリーの最適化と自動調整をオフにしてね。\n\n端末によって方法が変わるから各々調べてね",
"backup_controller_page_background_battery_info_ok": "了解",

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "미디어파일 백업 중...",
"backup_background_service_upload_failure_notification": "{} 업로드 실패",
"backup_controller_page_albums": "백업대상",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "사용 가이드",
"backup_controller_page_background_battery_info_message": "최상의 백업 환경을 위해 Immich 앱의 백그라운드 활동을 제한하는 배터리 최적화기능을 꺼주세요.\n\n휴대폰마다 설정방법이 다르므로 제조업체별로 설정방법을 확인하세요.",
"backup_controller_page_background_battery_info_ok": "확인",
@@ -226,5 +229,5 @@
"version_announcement_overlay_text_1": "안녕하세요!",
"version_announcement_overlay_text_2": "앱에 새로운 업데이트가 있습니다!",
"version_announcement_overlay_text_3": "특히 WatchTower 또는 서버 응용 프로그램 자동 업데이트를 처리하는 메커니즘을 사용하는 경우 잘못된 구성을 방지하기 위해 docker-compose 및 .env 설정이 최신 상태인지 확인하세요.",
"version_announcement_overlay_title": "새 서버 버전 사용 가능 🎉"
"version_announcement_overlay_title": "새 서버 버전 사용 가능 \uD83C\uDF89"
}

View File

@@ -1,6 +1,6 @@
{
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_album_bottom_sheet_added": "Toegevoegd aan {album}",
"add_to_album_bottom_sheet_already_exists": "Staat al in {album}",
"album_info_card_backup_album_excluded": "UITGESLOTEN",
"album_info_card_backup_album_included": "INGESLOTEN",
"album_thumbnail_card_item": "1 item",
@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Back-up maken van items…",
"backup_background_service_upload_failure_notification": "Fout bij upload {}",
"backup_controller_page_albums": "Back-up albums",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Toon me hoe",
"backup_controller_page_background_battery_info_message": "Schakel voor de beste back-up ervaring op de achtergrond alle batterij optimalisaties uit, die de achtergrondactiviteit van Immich beperkt.\n\nAangezien dit apparaatspecifiek is, zoek de vereiste informatie op voor de fabrikant van je apparaat.",
"backup_controller_page_background_battery_info_ok": "OK",
@@ -89,21 +92,21 @@
"cache_settings_subtitle": "Beheer het cachegedrag van de Immich app",
"cache_settings_thumbnail_size": "Thumbnail cachegrootte ({} items)",
"cache_settings_title": "Cache instellingen",
"change_password_form_confirm_password": "Confirm Password",
"change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
"change_password_form_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"common_add_to_album": "Add to album",
"common_change_password": "Change Password",
"common_create_new_album": "Create new album",
"common_shared": "Shared",
"change_password_form_confirm_password": "Bevestig wachtwoord",
"change_password_form_description": "Hallo {firstName} {lastName},\n\nDit is ofwel de eerste keer dat je inlogd of er is een verzoek gedaan om je wachtwoord te wijzigen. Vul hieronder een nieuw wachtwoord in.",
"change_password_form_new_password": "Nieuw wachtwoord",
"change_password_form_password_mismatch": "Wachtwoorden komen niet overeen",
"change_password_form_reenter_new_password": "Vul het wachtwoord opnieuw in",
"common_add_to_album": "Toevoegen aan album",
"common_change_password": "Wachtwoord wijzigen",
"common_create_new_album": "Maak nieuw album",
"common_shared": "Gedeeld",
"control_bottom_app_bar_add_to_album": "Toevoegen aan album",
"control_bottom_app_bar_album_info": "{} items",
"control_bottom_app_bar_album_info_shared": "{} items · Gedeeld",
"control_bottom_app_bar_create_new_album": "Maak nieuw album",
"control_bottom_app_bar_delete": "Verwijderen",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_favorite": "Favoriet",
"control_bottom_app_bar_share": "Delen",
"create_album_page_untitled": "Naamloos",
"create_shared_album_page_create": "Aanmaken",
@@ -126,13 +129,13 @@
"experimental_settings_title": "Experimenteel",
"favorites_page_title": "Favorieten",
"home_page_add_to_album_conflicts": "{added} items toegevoegd aan album {album}. {failed} items staan al in het album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_err_local": "Lokale items kunnen nog niet aan albums worden toegevoegd, overslaan",
"home_page_add_to_album_success": "{added} items toegevoegd aan album {album}.",
"home_page_building_timeline": "Tijdlijn opbouwen",
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_favorite_err_local": "Lokale items kunnen nog niet als favoriet worden aangemerkt, overslaan",
"home_page_first_time_notice": "Als dit de eerste keer is dat je de app gebruikt, zorg er dan voor dat je een back-up album kiest, zodat de tijdlijn gevuld kan worden met foto's en video's uit het album.",
"image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_success": "Download Success",
"image_viewer_page_state_provider_download_error": "Download mislukt",
"image_viewer_page_state_provider_download_success": "Download succesvol",
"library_page_albums": "Albums",
"library_page_favorites": "Favorieten",
"library_page_new_album": "Nieuw album",
@@ -156,12 +159,12 @@
"login_form_password_hint": "wachtwoord",
"login_form_save_login": "Ingelogd blijven",
"monthly_title_text_date_format": "MMMM y",
"notification_permission_dialog_cancel": "Cancel",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_dialog_settings": "Settings",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
"notification_permission_list_tile_title": "Notification Permission",
"notification_permission_dialog_cancel": "Annuleren",
"notification_permission_dialog_content": "Om meldingen in te schakelen, ga naar Instellingen en selecteer toestaan.",
"notification_permission_dialog_settings": "Instellingen",
"notification_permission_list_tile_content": "Geef toestemming om meldingen in te schakelen.",
"notification_permission_list_tile_enable_button": "Meldingen inschakelen",
"notification_permission_list_tile_title": "Toestemming meldingen",
"profile_drawer_app_logs": "Logboek",
"profile_drawer_client_server_up_to_date": "App en server zijn up-to-date",
"profile_drawer_settings": "Instellingen",
@@ -175,8 +178,8 @@
"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",
"server_info_box_app_version": "App Version",
"server_info_box_server_version": "Server Version",
"server_info_box_app_version": "App versie",
"server_info_box_server_version": "Server versie",
"setting_image_viewer_help": "De gedetailleerde weergave laadt eerst de kleine thumbnail, vervolgens het middelgrote voorbeeld (indien ingeschakeld) en ten slotte het origineel (indien ingeschakeld).",
"setting_image_viewer_original_subtitle": "Inschakelen om de originele afbeelding met volledige resolutie (groot!) te laden. Uitschakelen om datagebruik te verminderen (zowel netwerk- als apparaatcache).",
"setting_image_viewer_original_title": "Originele afbeelding laden",

View File

@@ -0,0 +1,233 @@
{
"add_to_album_bottom_sheet_added": "Lagt til i {album}",
"add_to_album_bottom_sheet_already_exists": "Allerede i {album}",
"album_info_card_backup_album_excluded": "EKSKLUDERT",
"album_info_card_backup_album_included": "INKLUDERT",
"album_thumbnail_card_item": "1 objekt",
"album_thumbnail_card_items": "{} objekter",
"album_thumbnail_card_shared": "Delt",
"album_viewer_appbar_share_delete": "Slett album",
"album_viewer_appbar_share_err_delete": "Feilet ved sletting av album",
"album_viewer_appbar_share_err_leave": "Kunne ikke forlate albumet",
"album_viewer_appbar_share_err_remove": "Det oppstod ett problem ved fjerning av objekter fra albumet",
"album_viewer_appbar_share_err_title": "Feilet ved endring av albumtittel",
"album_viewer_appbar_share_leave": "Forlat album",
"album_viewer_appbar_share_remove": "Fjern fra album",
"album_viewer_page_share_add_users": "Legg til brukere",
"asset_list_layout_settings_dynamic_layout_title": "Dynamisk layout",
"asset_list_layout_settings_group_by": "Grupper bilder etter",
"asset_list_layout_settings_group_by_month": "Måned",
"asset_list_layout_settings_group_by_month_day": "Måned + dag",
"asset_list_settings_subtitle": "Innstillinger for layout for fotorutenett",
"asset_list_settings_title": "Fotorutenett",
"backup_album_selection_page_albums_device": "Albumer på enhet ({})",
"backup_album_selection_page_albums_tap": "Trykk for å inkludere, dobbelttrykk for å ekskludere",
"backup_album_selection_page_assets_scatter": "Objekter kan eksistere i flere album. Men album kan kun bli inkludert eller ekskludert under backup-prosessen.",
"backup_album_selection_page_select_albums": "Velg album",
"backup_album_selection_page_selection_info": "Valginfo",
"backup_album_selection_page_total_assets": "Totalt unike objekter",
"backup_all": "Alle",
"backup_background_service_backup_failed_message": "Feilet ved backup av objekter. Prøver på nytt...",
"backup_background_service_connection_failed_message": "Feilet ved tilkobling til server. Prøver på nytt...",
"backup_background_service_current_upload_notification": "Laster opp {}",
"backup_background_service_default_notification": "Sjekker for nye objekter...",
"backup_background_service_error_title": "Backup feil",
"backup_background_service_in_progress_notification": "Foretar backup av objekter...",
"backup_background_service_upload_failure_notification": "Filet under opplasting {}",
"backup_controller_page_albums": "Sikkerhetskopier albumer",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Hvis meg hvordan",
"backup_controller_page_background_battery_info_message": "For den beste bakgrunnsbackup opplevelsen, deaktiver enhver batterioptimalisering som begrenser bakgrunnsaktiviteten til Immich.\n\nSiden dette er en enhets-spesifik justering, se innstillinger for den aktuelle enhet.",
"backup_controller_page_background_battery_info_ok": "OK",
"backup_controller_page_background_battery_info_title": "Batterioptimalisering",
"backup_controller_page_background_charging": "Kun ved lading",
"backup_controller_page_background_configure_error": "Feilet under konfigurering av bakgrunnstjenesten",
"backup_controller_page_background_delay": "Forsink backup av nye objekter: {}",
"backup_controller_page_background_description": "Skru på bakgrunnstjenesten for å automatisk ta backup av alle nye objekter uten å måtte åpne appen",
"backup_controller_page_background_is_off": "Automatisk bakgrunnsbackup er deaktivert",
"backup_controller_page_background_is_on": "Automatisk bakgrunnsbackup er aktivert",
"backup_controller_page_background_turn_off": "Skru av bakgrunnstjenesten",
"backup_controller_page_background_turn_on": "Skru på bakgrunnstjenesten",
"backup_controller_page_background_wifi": "Kun på WiFi",
"backup_controller_page_backup": "Sikkerhetskopier",
"backup_controller_page_backup_selected": "Valgte:",
"backup_controller_page_backup_sub": "Opplastede bilder og videoer",
"backup_controller_page_cancel": "Avbryt",
"backup_controller_page_created": "Opprettet den: {}",
"backup_controller_page_desc_backup": "Slå på sikkerhetskopiering i forgrunnen for automatisk å laste opp nye objekter til serveren når du åpner appen.",
"backup_controller_page_excluded": "Ekskludert:",
"backup_controller_page_failed": "Mislyktes ({})",
"backup_controller_page_filename": "Filnavn: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Sikkerhetskopi informasjon",
"backup_controller_page_none_selected": "Ingen valgt",
"backup_controller_page_remainder": "Gjenstår",
"backup_controller_page_remainder_sub": "Gjenstående bilder og videoer å laste opp fra utvalg",
"backup_controller_page_select": "Velg",
"backup_controller_page_server_storage": "Serverlagring",
"backup_controller_page_start_backup": "Start backup",
"backup_controller_page_status_off": "Automatisk sikkerhetskopiering i forgrunnen er av",
"backup_controller_page_status_on": "Automatisk sikkerhetskopiering i forgrunnen er på",
"backup_controller_page_storage_format": "{} av {} brukt",
"backup_controller_page_to_backup": "Albumer som skal sikkerhetskopieres",
"backup_controller_page_total": "Totalt",
"backup_controller_page_total_sub": "Alle unike bilder og videoer fra valgte album",
"backup_controller_page_turn_off": "Slå av sikkerhetskopiering i forgrunnen",
"backup_controller_page_turn_on": "Slå på sikkerhetskopiering i forgrunnen",
"backup_controller_page_uploading_file_info": "Filinformasjon på opplastende fil",
"backup_err_only_album": "Kan ikke fjerne det eneste albumet",
"backup_info_card_assets": "objekter",
"cache_settings_album_thumbnails": "Bibliotekside miniatyrbilder ({} objekter)",
"cache_settings_clear_cache_button": "Tøm buffer",
"cache_settings_clear_cache_button_title": "Tømmer app'ens buffer. Dette vil ha betydelig innvirkning på appens ytelse inntil buffer er gjenoppbygd.",
"cache_settings_image_cache_size": "Bilde bufferstørrelse ({} objekter)",
"cache_settings_statistics_album": "Bibliotek miniatyrbilder",
"cache_settings_statistics_assets": "{} objekter ({})",
"cache_settings_statistics_full": "Originalbilder",
"cache_settings_statistics_shared": "Delte album miniatyrbilder",
"cache_settings_statistics_thumbnail": "Miniatyrbilder",
"cache_settings_statistics_title": "Bufferbruk",
"cache_settings_subtitle": "Kontroller bufringsadferden til Immich-mobilapplikasjonen",
"cache_settings_thumbnail_size": "MIniatyrbilder bufferstørrelse ({} objekter)",
"cache_settings_title": "Bufringsinnstillinger",
"change_password_form_confirm_password": "Bekreft passord",
"change_password_form_description": "Hei {firstName} {lastName}!\n\nDette er enten første gang du logger på systemet, eller det er sendt en forespørsel om å endre passordet ditt. Vennligst skriv inn det nye passordet nedenfor.",
"change_password_form_new_password": "Nytt passord",
"change_password_form_password_mismatch": "Passordene stemmer ikke",
"change_password_form_reenter_new_password": "Skriv nytt passord igjen",
"common_add_to_album": "Legg til i album",
"common_change_password": "Endre passord",
"common_create_new_album": "Lag nytt album",
"common_shared": "Delt",
"control_bottom_app_bar_add_to_album": "Legg til i album",
"control_bottom_app_bar_album_info": "{} objekter",
"control_bottom_app_bar_album_info_shared": "{} objekter · Delt",
"control_bottom_app_bar_create_new_album": "Lag nytt album",
"control_bottom_app_bar_delete": "Slett",
"control_bottom_app_bar_favorite": "Favoritt",
"control_bottom_app_bar_share": "Del",
"create_album_page_untitled": "Uten navn",
"create_shared_album_page_create": "Opprett",
"create_shared_album_page_share": "Del",
"create_shared_album_page_share_add_assets": "LEGG TIL OBJEKTER",
"create_shared_album_page_share_select_photos": "Velg bilder",
"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": "Disse objektene vil bli slettet permanent fra Immich og fra enheten",
"delete_dialog_cancel": "Avbryt",
"delete_dialog_ok": "Slett",
"delete_dialog_title": "Slett permanent",
"exif_bottom_sheet_description": "Legg til beskrivelse...",
"exif_bottom_sheet_details": "DETALJER",
"exif_bottom_sheet_location": "PLASSERING",
"experimental_settings_new_asset_list_subtitle": "Under utvikling",
"experimental_settings_new_asset_list_title": "Aktiver eksperimentell grid-visning",
"experimental_settings_subtitle": "Bruk på egen risiko!",
"experimental_settings_title": "Eksperimentelt",
"favorites_page_title": "Favoritter",
"home_page_add_to_album_conflicts": "Lagt til {added} objekter til album {album}. {failed} objekter er allerede i albumet.",
"home_page_add_to_album_err_local": "Kan ikke legge til lokale objekter til album enda, hopper over",
"home_page_add_to_album_success": "Lagt til {added} objekter til album {album}.",
"home_page_building_timeline": "Genererer tidslinjen",
"home_page_favorite_err_local": "Kan ikke sette favoritt på lokale objekter enda, hopper over",
"home_page_first_time_notice": "Hvis dette er første gangen du benytter appen, så velg ett album(eller flere) slik at tidslinjen kan genereres med dine bilder og videoer.",
"image_viewer_page_state_provider_download_error": "Nedlasting feilet",
"image_viewer_page_state_provider_download_success": "Nedlasting vellykket",
"library_page_albums": "Albumer",
"library_page_favorites": "Favoritter",
"library_page_new_album": "Nytt album",
"library_page_sharing": "Deling",
"library_page_sort_created": "Nylig opplastet",
"library_page_sort_title": "Album tittel",
"login_form_button_text": "Logg inn",
"login_form_email_hint": "dinepost@epost.no",
"login_form_endpoint_hint": "http://din-server-ip:port/api",
"login_form_endpoint_url": "Serverendepunkt-URL",
"login_form_err_http": "Vennligst spesifiser http:// eller https://",
"login_form_err_invalid_email": "Ugyldig Epostadresse",
"login_form_err_invalid_url": "Ugyldig URL",
"login_form_err_leading_whitespace": "Ledende mellomrom",
"login_form_err_trailing_whitespace": "Etterfølgende mellomrom",
"login_form_failed_get_oauth_server_config": "Feil innlogging ved bruk av OAuth, sjekk server URL",
"login_form_failed_get_oauth_server_disable": "OAuth innlogging er ikke tilgjengelig på denne serveren",
"login_form_failed_login": "Feil ved innlogging, sjekk server URL, epost og passord",
"login_form_label_email": "Epostadresse",
"login_form_label_password": "Passord",
"login_form_password_hint": "passord",
"login_form_save_login": "Forbli innlogget",
"monthly_title_text_date_format": "MMMM y",
"notification_permission_dialog_cancel": "Avbryt",
"notification_permission_dialog_content": "For å aktivere notifikasjoner, gå til Innstillinger og velg tillat.",
"notification_permission_dialog_settings": "Innstillinger",
"notification_permission_list_tile_content": "Tillat tilgang for å aktivere notifikasjoner",
"notification_permission_list_tile_enable_button": "Aktiver notifikasjoner",
"notification_permission_list_tile_title": "Notifikasjonstilgang",
"profile_drawer_app_logs": "Logg",
"profile_drawer_client_server_up_to_date": "Klient og Server er oppdatert",
"profile_drawer_settings": "Innstillinger",
"profile_drawer_sign_out": "Logg ut",
"search_bar_hint": "Søk i dine bilder",
"search_page_no_objects": "Ingen objektinfo tilgjengelig",
"search_page_no_places": "Ingen plasseringsinfo tilgjengelig",
"search_page_places": "Plasser",
"search_page_things": "Ting",
"search_result_page_new_search_hint": "Nytt søk",
"select_additional_user_for_sharing_page_suggestions": "Forslag",
"select_user_for_sharing_page_err_album": "Feilet ved oppretting av album",
"select_user_for_sharing_page_share_suggestions": "Forslag",
"server_info_box_app_version": "App versjon",
"server_info_box_server_version": "Server versjon",
"setting_image_viewer_help": "Først lastes mikrobilder, deretter middels-oppløsningbildet (hvis aktivert), til slutt lastes original (hvis aktivert).",
"setting_image_viewer_original_subtitle": "Aktiver for å laste originalbildet i full oppløsning (Stort!). Deaktiver for å spare databruk (både nettverksbruk og bufferdata på enheten).",
"setting_image_viewer_original_title": "Last originalbildet",
"setting_image_viewer_preview_subtitle": "Aktiver for å laste ett bilde av middels-oppløsning. Deaktiver for å enten direkte laste inn originalen eller kun benytte miniatyrbilde.",
"setting_image_viewer_preview_title": "Last forhåndsvisningsbilde",
"setting_notifications_notify_failures_grace_period": "Varsle om sikkerhetskopieringsfeil i bakgrunnen: {}",
"setting_notifications_notify_hours": "{} timer",
"setting_notifications_notify_immediately": "umiddelbart",
"setting_notifications_notify_minutes": "{} minutter",
"setting_notifications_notify_never": "aldri",
"setting_notifications_notify_seconds": "{} sekunder",
"setting_notifications_single_progress_subtitle": "Detaljert opplastingsinformasjon pr objekt",
"setting_notifications_single_progress_title": "Vis detaljert status på bakgrunnsbackup",
"setting_notifications_subtitle": "Juster notifikasjonsinnstillinger",
"setting_notifications_title": "Notifikasjoner",
"setting_notifications_total_progress_subtitle": "Total opplastingsstatus (fullført/totalt objekter)",
"setting_notifications_total_progress_title": "Vis status på bakgrunnsbackup",
"setting_pages_app_bar_settings": "Innstillinger",
"settings_require_restart": "Vennligst restart Immich for å aktivere denne innstillingen",
"share_add": "Legg til",
"share_add_photos": "Legg til bilder",
"share_add_title": "Legg til tittel",
"share_create_album": "Opprett album",
"share_dialog_preparing": "Forbereder...",
"share_invite": "Inviter til album",
"sharing_page_album": "Delte album",
"sharing_page_description": "Lag delte album for å dele bilder og videoer med folk i nettverket ditt.",
"sharing_page_empty_list": "TOM LISTE",
"sharing_silver_appbar_create_shared_album": "Lag delt album",
"sharing_silver_appbar_share_partner": "Del med partner",
"tab_controller_nav_library": "Bibliotek",
"tab_controller_nav_photos": "Bilder",
"tab_controller_nav_search": "Søk",
"tab_controller_nav_sharing": "Deling",
"theme_setting_asset_list_storage_indicator_title": "Vis lagringsindiaktor på objektfliser",
"theme_setting_asset_list_tiles_per_row_title": "Antall ressurser per rad ({})",
"theme_setting_dark_mode_switch": "Mørk modus",
"theme_setting_image_viewer_quality_subtitle": "Juster kvaliteten på detaljer med bildeviser",
"theme_setting_image_viewer_quality_title": "Bilderviser-kvalitet",
"theme_setting_system_theme_switch": "Automatisk (følg system)",
"theme_setting_theme_subtitle": "Velg app'ens temainnstilling",
"theme_setting_theme_title": "Tema",
"theme_setting_three_stage_loading_subtitle": "Tre-trinns lasting kan øke lasteytelsen, men forårsaker betydelig høyere nettverksbelastning",
"theme_setting_three_stage_loading_title": "Aktiver 3-stegs innlasting",
"version_announcement_overlay_ack": "Bekreft",
"version_announcement_overlay_release_notes": "Endringslogg",
"version_announcement_overlay_text_1": "Hei, det er en ny versjon av",
"version_announcement_overlay_text_2": "vennligst ta deg tid til å besøke",
"version_announcement_overlay_text_3": "og verifiser at din docker-compose og .env oppsett er oppdatert for å forhindre en eventuell miskonfigurasjon. Spesielt hvis du benytter WatchTower eller en annen tjeneste som håndterer oppdatering av applikasjoner på serveren automatisk.",
"version_announcement_overlay_title": "Ny serverversjon tilgjengelig"
}

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Tworzę kopię twoich zasobów...",
"backup_background_service_upload_failure_notification": "Nie udało się przesłać {}",
"backup_controller_page_albums": "Backup Albumów",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Pokaż mi jak",
"backup_controller_page_background_battery_info_message": "Aby uzyskać najlepsze rezultaty podczas tworzenia kopii zapasowej w tle, należy wyłączyć wszelkie optymalizacje baterii ograniczające aktywność w tle dla Immich w urządzeniu.\n\nPonieważ jest to zależne od urządzenia, proszę sprawdzić wymagane informacje dla producenta urządzenia.",
"backup_controller_page_background_battery_info_ok": "OK",

View File

@@ -1,6 +1,6 @@
{
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_album_bottom_sheet_added": "Добавлено в {album}",
"add_to_album_bottom_sheet_already_exists": "Уже в {album}",
"album_info_card_backup_album_excluded": "ИСКЛЮЧЕН",
"album_info_card_backup_album_included": "ВКЛЮЧЕН",
"album_thumbnail_card_item": "1 объект",
@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Резервное копирование ваших объектов…",
"backup_background_service_upload_failure_notification": "Ошибка загрузки {}",
"backup_controller_page_albums": "Резервное копирование альбомов",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Показать как",
"backup_controller_page_background_battery_info_message": "Для наилучшего фонового резервного копирования отключите любые настройки оптимизации батареи, ограничивающие фоновую активность для Immich.\n\nПоскольку это зависит от устройства, найдите необходимую информацию для производителя вашего устройства.",
"backup_controller_page_background_battery_info_ok": "ОК",
@@ -89,21 +92,21 @@
"cache_settings_subtitle": "Управление кэшированием мобильного приложения Immich",
"cache_settings_thumbnail_size": "Размер кэша эскизов ({} объектов)",
"cache_settings_title": "Настройки кэширования",
"change_password_form_confirm_password": "Confirm Password",
"change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
"change_password_form_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"common_add_to_album": "Add to album",
"common_change_password": "Change Password",
"common_create_new_album": "Create new album",
"common_shared": "Shared",
"change_password_form_confirm_password": "Подтвердите пароль",
"change_password_form_description": "Привет {firstName} {lastName},\n\nЭто либо ваш первый вход в систему, либо был сделан запрос на смену пароля. Пожалуйста, введите новый пароль ниже.",
"change_password_form_new_password": "Новый пароль",
"change_password_form_password_mismatch": "Пароли не совпадают",
"change_password_form_reenter_new_password": "Повторно введите новый пароль",
"common_add_to_album": "Добавить в альбом",
"common_change_password": "Изменить пароль",
"common_create_new_album": "Создать новый альбом",
"common_shared": "Общие",
"control_bottom_app_bar_add_to_album": "Добавить в альбом",
"control_bottom_app_bar_album_info": "{} файлов",
"control_bottom_app_bar_album_info_shared": "{} файлов · Общий",
"control_bottom_app_bar_create_new_album": "\nСоздать новый альбом",
"control_bottom_app_bar_delete": "Удалить",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_favorite": "Избранное",
"control_bottom_app_bar_share": "Поделиться",
"create_album_page_untitled": "Без названия",
"create_shared_album_page_create": "Создать",
@@ -126,13 +129,13 @@
"experimental_settings_title": "Экспериментальные функции",
"favorites_page_title": "Избранное",
"home_page_add_to_album_conflicts": "Добавлено {added} объектов в альбом {album}. Объекты {failed} уже есть в альбоме.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_err_local": "Пока нельзя добавлять локальные объекты в альбомы, пропускаем",
"home_page_add_to_album_success": "Добавлено {added} объектов в альбом {album}.",
"home_page_building_timeline": "Построение временной шкалы",
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_favorite_err_local": "Пока не удается добавить в избранное локальные объекты, пропускаем",
"home_page_first_time_notice": "Если вы используете приложение впервые, убедитесь, что вы выбрали резервный(е) альбом(ы), чтобы временная шкала могла заполнить фотографии и видео в альбоме(ах).",
"image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_success": "Download Success",
"image_viewer_page_state_provider_download_error": "Ошибка загрузки",
"image_viewer_page_state_provider_download_success": "Успешно загружено",
"library_page_albums": "Альбомы",
"library_page_favorites": "Избранное",
"library_page_new_album": "Новый альбом",
@@ -156,12 +159,12 @@
"login_form_password_hint": "пароль",
"login_form_save_login": "Оставаться в системе",
"monthly_title_text_date_format": "MMMM y",
"notification_permission_dialog_cancel": "Cancel",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_dialog_settings": "Settings",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
"notification_permission_list_tile_title": "Notification Permission",
"notification_permission_dialog_cancel": "Отмена",
"notification_permission_dialog_content": "Чтобы включить уведомления, перейдите в «Настройки» и выберите «Разрешить».",
"notification_permission_dialog_settings": "Настройки",
"notification_permission_list_tile_content": "Предоставьте разрешение на включение уведомлений",
"notification_permission_list_tile_enable_button": "Включить уведомления",
"notification_permission_list_tile_title": "Разрешение на уведомление",
"profile_drawer_app_logs": "Журналы",
"profile_drawer_client_server_up_to_date": "Клиент и сервер обновлены",
"profile_drawer_settings": "Настройки",
@@ -175,8 +178,8 @@
"select_additional_user_for_sharing_page_suggestions": "Предложения",
"select_user_for_sharing_page_err_album": "\nНе удалось создать альбом",
"select_user_for_sharing_page_share_suggestions": "Предложения",
"server_info_box_app_version": "App Version",
"server_info_box_server_version": "Server Version",
"server_info_box_app_version": "Версия приложения",
"server_info_box_server_version": "Версия сервера",
"setting_image_viewer_help": "Средство просмотра деталей сначала загружает маленькую миниатюру, затем загружает предварительный просмотр среднего размера (если включено) и, наконец, загружает оригинал (если включено).",
"setting_image_viewer_original_subtitle": "Включите загрузку оригинального изображения в полном разрешении (большое!). Отключите, чтобы уменьшить объем данных (как в сети, так и в кеше устройства).",
"setting_image_viewer_original_title": "Загрузить исходное изображение",
@@ -224,7 +227,7 @@
"version_announcement_overlay_ack": "Подтверждение",
"version_announcement_overlay_release_notes": "примечания к выпуску",
"version_announcement_overlay_text_1": "Привет друг, вышел новый релиз",
"version_announcement_overlay_text_2": "пожалуйста, найдите время, чтобы посетить",
"version_announcement_overlay_text_2": "пожалуйста, найдите время, чтобы посетить ",
"version_announcement_overlay_text_3": " и убедитесь, что ваши настройки docker-compose и .env обновлены, чтобы предотвратить любые неправильные настройки, особенно если вы используете WatchTower или любой другой механизм, который обрабатывает обновление вашего серверного приложения автоматически.",
"version_announcement_overlay_title": "Доступна новая версия сервера \uD83C\uDF89"
}
}

View File

@@ -1,6 +1,6 @@
{
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_album_bottom_sheet_added": "Pridané do {album}",
"add_to_album_bottom_sheet_already_exists": "Už v {album}",
"album_info_card_backup_album_excluded": "VYLÚČENÉ",
"album_info_card_backup_album_included": "ZAHRNUTÉ",
"album_thumbnail_card_item": "1 položka",
@@ -14,10 +14,10 @@
"album_viewer_appbar_share_leave": "Opustiť album",
"album_viewer_appbar_share_remove": "Odstrániť z albumu",
"album_viewer_page_share_add_users": "Pridať používateľov",
"asset_list_layout_settings_dynamic_layout_title": "Dynamic layout",
"asset_list_layout_settings_group_by": "Group assets by",
"asset_list_layout_settings_group_by_month": "Month",
"asset_list_layout_settings_group_by_month_day": "Month + day",
"asset_list_layout_settings_dynamic_layout_title": "Dynamické rozloženie",
"asset_list_layout_settings_group_by": "Zoskupiť položky podľa",
"asset_list_layout_settings_group_by_month": "Mesiac",
"asset_list_layout_settings_group_by_month_day": "Mesiac + d",
"asset_list_settings_subtitle": "Nastavenia rozloženia mriežky fotografií",
"asset_list_settings_title": "Fotografická mriežka",
"backup_album_selection_page_albums_device": "Albumy v zariadení ({})",
@@ -27,21 +27,24 @@
"backup_album_selection_page_selection_info": "Informácie o výbere",
"backup_album_selection_page_total_assets": "Celkový počet jedinečných súborov",
"backup_all": "Všetko",
"backup_background_service_backup_failed_message": "Zálohovanie zdrojov zlyhalo. Skúšam to znova...",
"backup_background_service_backup_failed_message": "Zálohovanie médií zlyhalo. Skúšam to znova...",
"backup_background_service_connection_failed_message": "Nepodarilo sa pripojiť k serveru. Skúšam to znova...",
"backup_background_service_current_upload_notification": "Nahrávanie {}",
"backup_background_service_default_notification": "Kontrola nových zdrojov {}",
"backup_background_service_default_notification": "Kontrola nových médií {}",
"backup_background_service_error_title": "Chyba zálohovania",
"backup_background_service_in_progress_notification": "Vytváram kópiu vašich zdrojov...",
"backup_background_service_in_progress_notification": "Vytváram kópiu vašich médií...",
"backup_background_service_upload_failure_notification": "Nepodarilo sa nahrať {}",
"backup_controller_page_albums": "Zálohované albumy",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Ukáž mi ako",
"backup_controller_page_background_battery_info_message": "Ak chcete dosiahnuť najlepšie výsledky pri zálohovaní na pozadí, vypnite všetky optimalizácie batérie, ktoré obmedzujú aktivitu na pozadí pre Immich vo vašom zariadení. Keďže to závisí od zariadenia, skontrolujte požadované informácie pre výrobcu vášho zariadenia.",
"backup_controller_page_background_battery_info_ok": "OK",
"backup_controller_page_background_battery_info_title": "Optimalizácia batérie",
"backup_controller_page_background_charging": "Len počas nabíjania",
"backup_controller_page_background_configure_error": "Nepodarilo sa nakonfigurovať službu na pozadí",
"backup_controller_page_background_delay": "Oneskorenie zálohovania nových zdrojov: {}",
"backup_controller_page_background_delay": "Oneskorenie zálohovania nových médií: {}",
"backup_controller_page_background_description": "Povoľte službu na pozadí na automatické zálohovanie všetkých nových aktív bez nutnosti otvorenia aplikácie",
"backup_controller_page_background_is_off": "Automatické zálohovanie na pozadí je vypnuté",
"backup_controller_page_background_is_on": "Automatické zálohovanie na pozadí je zapnuté",
@@ -89,21 +92,21 @@
"cache_settings_subtitle": "Ovládanie správania mobilnej aplikácie Immich v medzipamäti",
"cache_settings_thumbnail_size": "Veľkosť vyrovnávacej pamäte náhľadov (položiek {})",
"cache_settings_title": "Nastavenia vyrovnávacej pamäte",
"change_password_form_confirm_password": "Confirm Password",
"change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
"change_password_form_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"common_add_to_album": "Add to album",
"common_change_password": "Change Password",
"common_create_new_album": "Create new album",
"common_shared": "Shared",
"change_password_form_confirm_password": "Potvrďte heslo",
"change_password_form_description": "Dobrý deň, {firstName} {lastName},\n\nBuď sa do systému prihlasujete prvýkrát, alebo bola podaná žiadosť o zmenu hesla. Prosím, zadajte nové heslo nižšie.",
"change_password_form_new_password": "Nové heslo",
"change_password_form_password_mismatch": "Heslá sa nezhodujú",
"change_password_form_reenter_new_password": "Znova zadajte nové heslo",
"common_add_to_album": "Pridať do albumu",
"common_change_password": "Zmeniť heslo",
"common_create_new_album": "Vytvoriť nový album",
"common_shared": "Zdieľané",
"control_bottom_app_bar_add_to_album": "Pridať do albumu",
"control_bottom_app_bar_album_info": "{} položky",
"control_bottom_app_bar_album_info_shared": "{} položky - zdieľané",
"control_bottom_app_bar_create_new_album": "Vytvoriť nový album",
"control_bottom_app_bar_delete": "Vymazať",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_favorite": "Obľúbené",
"control_bottom_app_bar_share": "Zdieľať",
"create_album_page_untitled": "Bez názvu",
"create_shared_album_page_create": "Vytvoriť",
@@ -126,13 +129,13 @@
"experimental_settings_title": "Experimentálne",
"favorites_page_title": "Obľúbené",
"home_page_add_to_album_conflicts": "Pridané {added} položky do albumu {album}. {failed} položky sú už v albume.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_err_local": "Zatiaľ nie je možné pridať lokálne média do albumov, preskakuje sa",
"home_page_add_to_album_success": "Pridané {added} položky do albumu {album}.",
"home_page_building_timeline": "Vytváranie časovej osi",
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_first_time_notice": "Ak aplikáciu používate prvýkrát, nezabudnite si vybrať zálohované albumy, aby sa na časovej osi mohli nachádzať fotografie a videá z vybraných albumoch.",
"image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_success": "Download Success",
"home_page_favorite_err_local": "Zatiaľ nie je možné zaradiť lokálne média medzi obľúbené, preskakuje sa",
"home_page_first_time_notice": "Ak aplikáciu používate prvý krát, nezabudnite si vybrať zálohované albumy, aby sa na časovej osi mohli nachádzať fotografie a videá z vybraných albumoch.",
"image_viewer_page_state_provider_download_error": "Chyba sťahovania",
"image_viewer_page_state_provider_download_success": "Sťahovanie bolo úspešné",
"library_page_albums": "Albumy",
"library_page_favorites": "Obľúbené",
"library_page_new_album": "Nový album",
@@ -145,7 +148,7 @@
"login_form_endpoint_url": "URL adresa servera",
"login_form_err_http": "Prosím, uveďte http:// alebo https://",
"login_form_err_invalid_email": "Neplatný e-mail",
"login_form_err_invalid_url": "Neplatná URL",
"login_form_err_invalid_url": "Neplatná URL adresa",
"login_form_err_leading_whitespace": "Úvodná medzera",
"login_form_err_trailing_whitespace": "Koncové medzera",
"login_form_failed_get_oauth_server_config": "Chyba prihlásenia pomocou OAuth, skontrolujte adresu URL servera",
@@ -156,12 +159,12 @@
"login_form_password_hint": "heslo",
"login_form_save_login": "Zostať prihlásený",
"monthly_title_text_date_format": "LLLL y",
"notification_permission_dialog_cancel": "Cancel",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_dialog_settings": "Settings",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
"notification_permission_list_tile_title": "Notification Permission",
"notification_permission_dialog_cancel": "Zrušiť",
"notification_permission_dialog_content": "Ak chcete povoliť upozornenia, prejdite do Nastavenia a vyberte možnosť Povoliť.",
"notification_permission_dialog_settings": "Nastavenia",
"notification_permission_list_tile_content": "Udeľte oprávnenie k aktivácii oznámení.",
"notification_permission_list_tile_enable_button": "Povoliť upozornenia",
"notification_permission_list_tile_title": "Povolenie oznámení",
"profile_drawer_app_logs": "Logy",
"profile_drawer_client_server_up_to_date": "Klient a server sú aktuálne",
"profile_drawer_settings": "Nastavenia",
@@ -175,12 +178,12 @@
"select_additional_user_for_sharing_page_suggestions": "Návrhy",
"select_user_for_sharing_page_err_album": "Nepodarilo sa vytvoriť album",
"select_user_for_sharing_page_share_suggestions": "Návrhy",
"server_info_box_app_version": "App Version",
"server_info_box_server_version": "Server Version",
"setting_image_viewer_help": "V prehliadači detailov sa najprv načíta malá miniatúra, potom sa načíta náhľad strednej veľkosti (ak je povolený) a nakoniec sa načíta originál (ak je povolený).",
"setting_image_viewer_original_subtitle": "Umožňuje načítať pôvodný obrázok v plnom rozlíšení (veľký!). Zakázať pre zníženie používania dát (v sieti aj v medzipamäti zariadenia).",
"server_info_box_app_version": "Verzia aplikácie",
"server_info_box_server_version": "Verzia servera",
"setting_image_viewer_help": "Prehliadač detailov najprv načíta malú miniatúru, potom načíta náhľad strednej veľkosti (ak je povolený) a nakoniec načíta originál (ak je povolený).",
"setting_image_viewer_original_subtitle": "Povolením umožníte načítať pôvodný obrázok v plnom rozlíšení (veľký!). Zakázaním znížite používania dát (v sieti, aj v dočasnej pamäte zariadenia).",
"setting_image_viewer_original_title": "Načítať pôvodný obrázok",
"setting_image_viewer_preview_subtitle": "Umožňuje načítať obrázok so stredným rozlíšením. Zakážte, ak chcete priamo načítať originál alebo použiť iba miniatúru.",
"setting_image_viewer_preview_subtitle": "Povolením umožníte načítať obrázok so stredným rozlíšením. Zakážte, ak chcete priamo načítať originál alebo použiť iba miniatúru.",
"setting_image_viewer_preview_title": "Načítať náhľad obrázka",
"setting_notifications_notify_failures_grace_period": "Oznámenie o zlyhaní zálohovania na pozadí: {}",
"setting_notifications_notify_hours": "{} hodín",

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "Säkerhetskopierar dina foton och videor...",
"backup_background_service_upload_failure_notification": "Kunde inte ladda upp {}",
"backup_controller_page_albums": "Säkerhetskopiera album",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "Visa mig hur",
"backup_controller_page_background_battery_info_message": "För optimal säkerhetskopiering i bakgrunden bör du stänga av batterioptimering som begränsar bakgrundsaktivitet för Immich.\n\nEftersom detta är enhetsspecifikt så bör du söka instruktioner från din enhetstillverkare.",
"backup_controller_page_background_battery_info_ok": "OK",

View File

@@ -35,6 +35,9 @@
"backup_background_service_in_progress_notification": "正在备份…",
"backup_background_service_upload_failure_notification": "上传失败 {}",
"backup_controller_page_albums": "备份相册",
"backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
"backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
"backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
"backup_controller_page_background_battery_info_link": "怎么做",
"backup_controller_page_background_battery_info_message": "为了获得最佳的后台备份体验,请禁用任何限制 Immich 后台活动的电池优化。\n\n由于这是设备相关的因此请查找设备制造商所需的信息。",
"backup_controller_page_background_battery_info_ok": "我知道了",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:integration_test/integration_test.dart';
import 'package:isar/isar.dart';
@@ -35,9 +34,7 @@ class ImmichTestHelper {
}
static Future<void> loadApp(WidgetTester tester) async {
// Clear all data from Hive
await Hive.deleteFromDisk();
await app.openBoxes();
await EasyLocalization.ensureInitialized();
// Clear all data from Isar (reuse existing instance if available)
final db = Isar.getInstance() ?? await app.loadDb();
await Store.clear();
@@ -65,12 +62,13 @@ void immichWidgetTest(
}
Future<void> pumpUntilFound(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 120),
}) async {
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 120),
}) async {
bool found = false;
final timer = Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
final timer =
Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
while (found != true) {
await tester.pump();
found = tester.any(finder);

View File

@@ -72,7 +72,7 @@ post_install do |installer|
# 'PERMISSION_SPEECH_RECOGNIZER=1',
## dart: PermissionGroup.photos
# 'PERMISSION_PHOTOS=1',
'PERMISSION_PHOTOS=1',
## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
# 'PERMISSION_LOCATION=1',

View File

@@ -1,4 +1,6 @@
PODS:
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_native_splash (0.0.1):
- Flutter
@@ -49,6 +51,7 @@ PODS:
- Flutter
DEPENDENCIES:
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
@@ -76,6 +79,8 @@ SPEC REPOS:
- Toast
EXTERNAL SOURCES:
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
flutter_native_splash:
@@ -116,29 +121,30 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock/ios"
SPEC CHECKSUMS:
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
image_picker_ios: 58b9c4269cb176f89acea5e5d043c9358f2d25f8
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
video_player_avfoundation: 6d971a232d72e6ee25368378d48a079dea01f1cf
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
PODFILE CHECKSUM: 4a7e0475ea85ab7bf89955bc4c7ea9d18b54dfd8
PODFILE CHECKSUM: 0606648e8a9ecd5a59eafa5ab3187b45a9004a28
COCOAPODS: 1.11.3

View File

@@ -378,7 +378,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 87;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -514,7 +514,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 87;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -542,7 +542,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 87;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -4,6 +4,7 @@ import Flutter
import BackgroundTasks
import path_provider_ios
import photo_manager
import permission_handler_apple
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
@@ -30,6 +31,10 @@ import photo_manager
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
}
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)

View File

@@ -1,112 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Immich</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>en</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ru</string>
<string>se</string>
<string>sk</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.48.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>87</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
</plist>
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Immich</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>en</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ru</string>
<string>se</string>
<string>sk</string>
<string>zh</string>
<string>no</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.50.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>88</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
</dict>
</plist>

View File

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

View File

@@ -5,32 +5,34 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000542">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000301">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="11.028001">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="1.613972">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.912096">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.887872">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.65488">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.53884">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="110.93313">
<testcase classname="fastlane.lanes" name="4: build_app" time="64.096001">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="80.292248">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="6.017821">
<failure message="/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing&apos;&#10;Fastfile:30:in `block (2 levels) in parsing_binding&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane.rb:33:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:49:in `block in execute&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `execute&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:354:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:43:in `start&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/bin/fastlane:23:in `&lt;top (required)&gt;&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `load&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `&lt;main&gt;&apos;&#10;&#10;Error uploading ipa file: &#10; [Application Loader Error Output]: Error uploading &apos;/var/folders/lp/myp2frzj00g93mcbnz6cd8900000gn/T/279a4279-ff7e-48d3-b6fd-1ee020a5b0a9.ipa&apos;.
&#10;[Application Loader Error Output]: Unable to upload archive. Failed to get authorization for username &apos;alex.tran1502@gmail.com&apos; and password. (
&#10;[Application Loader Error Output]: The call to the altool completed with a non-zero exit status: 1. This indicates a failure." />

View File

@@ -20,6 +20,7 @@ const List<Locale> locales = [
Locale('sv', 'SE'),
Locale('sk', 'SK'),
Locale('zh', 'CN'),
Locale('no', 'NO'),
];
const String translationsPath = 'assets/i18n';

View File

@@ -9,17 +9,25 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/settings/providers/permission.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
@@ -34,34 +42,25 @@ import 'package:immich_mobile/utils/migration.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'constants/hive_box.dart';
import 'package:permission_handler/permission_handler.dart';
void main() async {
await initApp();
WidgetsFlutterBinding.ensureInitialized();
final db = await loadDb();
await initApp();
await migrateHiveToStoreIfNecessary();
await migrateJsonCacheIfNecessary();
await migrateDatabaseIfNeeded(db);
runApp(getMainWidget(db));
}
Future<void> openBoxes() async {
await Future.wait([
Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
Hive.openBox(userInfoBox),
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
Hive.openBox(hiveGithubReleaseInfoBox),
Hive.openBox(userSettingInfoBox),
EasyLocalization.ensureInitialized(),
]);
}
Future<void> initApp() async {
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
Hive.registerAdapter(ImmichLoggerMessageAdapter());
await openBoxes();
await EasyLocalization.ensureInitialized();
if (kReleaseMode && Platform.isAndroid) {
try {
@@ -73,7 +72,7 @@ Future<void> initApp() async {
}
// Initialize Immich Logger Service
ImmichLogger().init();
ImmichLogger();
var log = Logger("ImmichErrorLogger");
@@ -91,7 +90,16 @@ Future<void> initApp() async {
Future<Isar> loadDb() async {
final dir = await getApplicationDocumentsDirectory();
Isar db = await Isar.open(
[StoreValueSchema],
[
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
],
directory: dir.path,
maxSizeMiB: 256,
);
@@ -129,8 +137,10 @@ class ImmichAppState extends ConsumerState<ImmichApp>
ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed;
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
final permission = ref.watch(galleryPermissionNotifier);
if (isAuthenticated) {
// Needs to be logged in and have gallery permissions
if (isAuthenticated && (permission.isGranted || permission.isLimited)) {
ref.read(backupProvider.notifier).resumeBackup();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.watch(assetProvider.notifier).getAllAsset();
@@ -144,6 +154,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
ref
.watch(notificationPermissionProvider.notifier)
.getNotificationPermission();
ref
.watch(galleryPermissionNotifier.notifier)
.getGalleryPermissionStatus();
ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
@@ -152,6 +165,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
case AppLifecycleState.inactive:
debugPrint("[APP STATE] inactive");
ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive;
ImmichLogger().flush();
ref.watch(websocketProvider.notifier).disconnect();
ref.watch(backupProvider.notifier).cancelBackup();
@@ -195,6 +209,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
);
return MaterialApp(
localizationsDelegates: context.localizationDelegates,

View File

@@ -1,37 +1,43 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
AlbumNotifier(this._albumService, this._db) : super([]);
final AlbumService _albumService;
final AlbumCacheService _albumCacheService;
void _cacheState() {
_albumCacheService.put(state);
}
final Isar _db;
Future<void> getAllAlbums() async {
if (await _albumCacheService.isValid() && state.isEmpty) {
final albums = await _albumCacheService.get();
if (albums != null) {
state = albums;
}
}
final albums = await _albumService.getAlbums(isShared: false);
if (albums != null) {
final User me = Store.get(StoreKey.currentUser);
List<Album> albums = await _db.albums
.filter()
.owner((q) => q.isarIdEqualTo(me.isarId))
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
await Future.wait([
_albumService.refreshDeviceAlbums(),
_albumService.refreshRemoteAlbums(isShared: false),
]);
albums = await _db.albums
.filter()
.owner((q) => q.isarIdEqualTo(me.isarId))
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
_cacheState();
}
}
void deleteAlbum(Album album) {
Future<bool> deleteAlbum(Album album) async {
state = state.where((a) => a.id != album.id).toList();
_cacheState();
return _albumService.deleteAlbum(album);
}
Future<Album?> createAlbum(
@@ -39,20 +45,16 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
Set<Asset> assets,
) async {
Album? album = await _albumService.createAlbum(albumTitle, assets, []);
if (album != null) {
state = [...state, album];
_cacheState();
return album;
}
return null;
return album;
}
}
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(albumCacheServiceProvider),
ref.watch(dbProvider),
);
});

View File

@@ -58,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
);
}
void addNewAssets(List<Asset> assets) {
void addNewAssets(Iterable<Asset> assets) {
state = state.copyWith(
selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum,

View File

@@ -1,21 +1,18 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, this._sharedAlbumCacheService)
: super([]);
SharedAlbumNotifier(this._albumService, this._db) : super([]);
final AlbumService _albumService;
final SharedAlbumCacheService _sharedAlbumCacheService;
void _cacheState() {
_sharedAlbumCacheService.put(state);
}
final Isar _db;
Future<Album?> createSharedAlbum(
String albumName,
@@ -23,7 +20,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
Iterable<User> sharedUsers,
) async {
try {
var newAlbum = await _albumService.createAlbum(
final Album? newAlbum = await _albumService.createAlbum(
albumName,
assets,
sharedUsers,
@@ -31,61 +28,52 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
if (newAlbum != null) {
state = [...state, newAlbum];
_cacheState();
return newAlbum;
}
return newAlbum;
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
return null;
}
return null;
}
Future<void> getAllSharedAlbums() async {
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
final albums = await _sharedAlbumCacheService.get();
if (albums != null) {
state = albums;
}
var albums = await _db.albums
.filter()
.sharedEqualTo(true)
.sortByCreatedAtDesc()
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
List<Album>? sharedAlbums = await _albumService.getAlbums(isShared: true);
if (sharedAlbums != null) {
state = sharedAlbums;
_cacheState();
await _albumService.refreshRemoteAlbums(isShared: true);
albums = await _db.albums
.filter()
.sharedEqualTo(true)
.sortByCreatedAtDesc()
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
}
void deleteAlbum(Album album) {
Future<bool> deleteAlbum(Album album) {
state = state.where((a) => a.id != album.id).toList();
_cacheState();
return _albumService.deleteAlbum(album);
}
Future<bool> leaveAlbum(Album album) async {
var res = await _albumService.leaveAlbum(album);
if (res) {
state = state.where((a) => a.id != album.id).toList();
_cacheState();
await deleteAlbum(album);
return true;
} else {
return false;
}
}
Future<bool> removeAssetFromAlbum(
Album album,
Iterable<Asset> assets,
) async {
var res = await _albumService.removeAssetFromAlbum(album, assets);
if (res) {
return true;
} else {
return false;
}
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
return _albumService.removeAssetFromAlbum(album, assets);
}
}
@@ -93,13 +81,15 @@ final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
ref.watch(dbProvider),
);
});
final sharedAlbumDetailProvider =
FutureProvider.autoDispose.family<Album?, String>((ref, albumId) async {
FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async {
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
return await sharedAlbumService.getAlbumDetail(albumId);
final Album? a = await sharedAlbumService.getAlbumDetail(albumId);
await a?.loadSortedAssets();
return a;
});

View File

@@ -3,8 +3,8 @@ import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
final suggestedSharedUsersProvider =
FutureProvider.autoDispose<List<User>>((ref) async {
FutureProvider.autoDispose<List<User>>((ref) {
UserService userService = ref.watch(userServiceProvider);
return await userService.getAllUsers(isAll: false) ?? [];
return userService.getUsersInDb();
});

View File

@@ -1,34 +1,168 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider(
(ref) => AlbumService(
ref.watch(apiServiceProvider),
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
ref.watch(backupServiceProvider),
),
);
class AlbumService {
final ApiService _apiService;
final UserService _userService;
final SyncService _syncService;
final Isar _db;
final BackupService _backupService;
final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
AlbumService(this._apiService);
AlbumService(
this._apiService,
this._userService,
this._syncService,
this._db,
this._backupService,
);
Future<List<Album>?> getAlbums({required bool isShared}) async {
try {
final dto = await _apiService.albumApi
.getAllAlbums(shared: isShared ? isShared : null);
return dto?.map(Album.remote).toList();
} catch (e) {
debugPrint("Error getAllSharedAlbum ${e.toString()}");
return null;
/// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes
Future<bool> refreshDeviceAlbums() async {
if (!_localCompleter.isCompleted) {
// guard against concurrent calls
_log.info("refreshDeviceAlbums is already in progress");
return _localCompleter.future;
}
_localCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<String> excludedIds =
await _backupService.excludedAlbumsQuery().idProperty().findAll();
final List<String> selectedIds =
await _backupService.selectedAlbumsQuery().idProperty().findAll();
if (selectedIds.isEmpty) {
return false;
}
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
if (Platform.isIOS) {
// iOS and Android device album working principle differ significantly
// on iOS, an asset can be in multiple albums
// on Android, an asset can only be in exactly one album (folder!) at the same time
// thus, on Android, excluding an album can be done by ignoring that album
// however, on iOS, it it necessary to load the assets from all excluded
// albums and check every asset from any selected album against the set
// of excluded assets
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
_log.info("Found ${excludedAssets.length} assets to exclude");
}
// remove all excluded albums
onDevice.removeWhere((e) => excludedIds.contains(e.id));
_log.info(
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) {
if (Platform.isAndroid) {
// remove the virtual "Recent" album and keep and individual albums
// on Android, the virtual "Recent" `lastModified` value is always null
onDevice.removeWhere((e) => e.isAll);
_log.info("'Recents' is selected, keeping all individual albums");
}
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
_log.info("'Recents' is not selected, keeping only selected albums");
}
changes =
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
_log.info("Syncing completed. Changes: $changes");
} finally {
_localCompleter.complete(changes);
}
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
}
Future<Set<String>> _loadExcludedAssetIds(
List<AssetPathEntity> albums,
List<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (AssetPathEntity a in albums) {
if (excludedAlbumIds.contains(a.id)) {
final List<AssetEntity> assets =
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
result.addAll(assets.map((e) => e.id));
}
}
return result;
}
/// Checks remote albums (owned if `isShared` is false) for changes,
/// updates the local database and returns `true` if there were any changes
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
if (!_remoteCompleter.isCompleted) {
// guard against concurrent calls
return _remoteCompleter.future;
}
_remoteCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
await _userService.refreshUsers();
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumApi
.getAllAlbums(shared: isShared ? true : null);
if (serverAlbums == null) {
return false;
}
changes = await _syncService.syncRemoteAlbumsToDb(
serverAlbums,
isShared: isShared,
loadDetails: (dto) async => dto.assetCount == dto.assets.length
? dto
: (await _apiService.albumApi.getAlbumInfo(dto.id)) ?? dto,
);
} finally {
_remoteCompleter.complete(changes);
}
debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
}
Future<Album?> createAlbum(
@@ -37,56 +171,51 @@ class AlbumService {
Iterable<User> sharedUsers = const [],
]) async {
try {
final dto = await _apiService.albumApi.createAlbum(
AlbumResponseDto? remote = await _apiService.albumApi.createAlbum(
CreateAlbumDto(
albumName: albumName,
assetIds: assets.map((asset) => asset.remoteId!).toList(),
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
),
);
return dto != null ? Album.remote(dto) : null;
if (remote != null) {
Album album = await Album.remote(remote);
await _db.writeTxn(() => _db.albums.store(album));
return album;
}
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
return null;
}
return null;
}
/*
* Creates names like Untitled, Untitled (1), Untitled (2), ...
*/
String _getNextAlbumName(List<Album>? albums) {
Future<String> _getNextAlbumName() async {
const baseName = "Untitled";
for (int round = 0;; round++) {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (albums != null) {
for (int round = 0; round < albums.length; round++) {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (albums.where((a) => a.name == proposedName).isEmpty) {
return proposedName;
}
if (null ==
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
return proposedName;
}
}
return baseName;
}
Future<Album?> createAlbumWithGeneratedName(
Iterable<Asset> assets,
) async {
return createAlbum(
_getNextAlbumName(await getAlbums(isShared: false)),
await _getNextAlbumName(),
assets,
[],
);
}
Future<Album?> getAlbumDetail(String albumId) async {
try {
final dto = await _apiService.albumApi.getAlbumInfo(albumId);
return dto != null ? Album.remote(dto) : null;
} catch (e) {
debugPrint('Error [getAlbumDetail] ${e.toString()}');
return null;
}
Future<Album?> getAlbumDetail(int albumId) {
return _db.albums.get(albumId);
}
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
@@ -98,6 +227,10 @@ class AlbumService {
album.remoteId!,
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
);
if (result != null && result.successfullyAdded > 0) {
album.assets.addAll(assets);
await _db.writeTxn(() => album.assets.save());
}
return result;
} catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
@@ -110,26 +243,57 @@ class AlbumService {
Album album,
) async {
try {
var result = await _apiService.albumApi.addUsersToAlbum(
final result = await _apiService.albumApi.addUsersToAlbum(
album.remoteId!,
AddUsersDto(sharedUserIds: sharedUserIds),
);
return result != null;
if (result != null) {
album.sharedUsers
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
album.shared = result.shared;
await _db.writeTxn(() async {
await _db.albums.put(album);
await album.sharedUsers.save();
});
return true;
}
} catch (e) {
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
return false;
}
return false;
}
Future<bool> deleteAlbum(Album album) async {
try {
await _apiService.albumApi.deleteAlbum(album.remoteId!);
final userId = Store.get(StoreKey.currentUser).isarId;
if (album.owner.value?.isarId == userId) {
await _apiService.albumApi.deleteAlbum(album.remoteId!);
}
if (album.shared) {
final foreignAssets =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
await _db.writeTxn(() => _db.albums.delete(album.id));
final List<Album> albums =
await _db.albums.filter().sharedEqualTo(true).findAll();
final List<Asset> existing = [];
for (Album a in albums) {
existing.addAll(
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
);
}
final List<int> idsToRemove =
_syncService.sharedAssetsToRemove(foreignAssets, existing);
if (idsToRemove.isNotEmpty) {
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
}
} else {
await _db.writeTxn(() => _db.albums.delete(album.id));
}
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
return false;
}
return false;
}
Future<bool> leaveAlbum(Album album) async {
@@ -153,6 +317,8 @@ class AlbumService {
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
),
);
album.assets.removeAll(assets);
await _db.writeTxn(() => album.assets.update(unlink: assets));
return true;
} catch (e) {
@@ -173,6 +339,7 @@ class AlbumService {
),
);
album.name = newAlbumTitle;
await _db.writeTxn(() => _db.albums.put(album));
return true;
} catch (e) {

View File

@@ -1,46 +1,23 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
class BaseAlbumCacheService extends JsonCache<List<Album>> {
BaseAlbumCacheService(super.cacheFileName);
@Deprecated("only kept to remove its files after migration")
class _BaseAlbumCacheService extends JsonCache<List<Album>> {
_BaseAlbumCacheService(super.cacheFileName);
@override
void put(List<Album> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
void put(List<Album> data) {}
@override
Future<List<Album>?> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData =
mapList.map((e) => Album.fromJson(e)).whereNotNull().toList();
return responseData;
} catch (e) {
await invalidate();
debugPrint(e.toString());
return null;
}
}
Future<List<Album>?> get() => Future.value(null);
}
class AlbumCacheService extends BaseAlbumCacheService {
@Deprecated("only kept to remove its files after migration")
class AlbumCacheService extends _BaseAlbumCacheService {
AlbumCacheService() : super("album_cache");
}
class SharedAlbumCacheService extends BaseAlbumCacheService {
@Deprecated("only kept to remove its files after migration")
class SharedAlbumCacheService extends _BaseAlbumCacheService {
SharedAlbumCacheService() : super("shared_album_cache");
}
final albumCacheServiceProvider = Provider(
(ref) => AlbumCacheService(),
);
final sharedAlbumCacheServiceProvider = Provider(
(ref) => SharedAlbumCacheService(),
);

View File

@@ -25,7 +25,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider);
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final albumService = ref.watch(albumServiceProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider);

View File

@@ -1,26 +1,27 @@
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/shared/models/album.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class AlbumThumbnailCard extends StatelessWidget {
final Function()? onTap;
/// Whether or not to show the owner of the album (or "Owned")
/// in the subtitle of the album
final bool showOwner;
const AlbumThumbnailCard({
Key? key,
required this.album,
this.onTap,
this.showOwner = false,
}) : super(key: key);
final Album album;
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
return LayoutBuilder(
builder: (context, constraints) {
@@ -42,19 +43,47 @@ class AlbumThumbnailCard extends StatelessWidget {
);
}
buildAlbumThumbnail() {
return CachedNetworkImage(
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
buildAlbumThumbnail() => ImmichImage(
album.thumbnail.value,
width: cardSize,
height: cardSize,
);
buildAlbumTextRow() {
// Add the owner name to the subtitle
String? owner;
if (showOwner) {
if (album.ownerId == Store.get(StoreKey.currentUser).id) {
owner = 'album_thumbnail_owned'.tr();
} else if (album.ownerName != null) {
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
}
}
return RichText(
overflow: TextOverflow.fade,
text: TextSpan(
children: [
TextSpan(
text: album.assetCount == 1
? 'album_thumbnail_card_item'
.tr(args: ['${album.assetCount}'])
: 'album_thumbnail_card_items'
.tr(args: ['${album.assetCount}']),
style: TextStyle(
fontFamily: 'WorkSans',
fontSize: 12,
color: isDarkMode ? Colors.white : Colors.black,
),
),
if (owner != null) const TextSpan(text: ' · '),
if (owner != null)
TextSpan(
text: owner,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
cacheKey:
getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
);
}
@@ -72,7 +101,7 @@ class AlbumThumbnailCard extends StatelessWidget {
height: cardSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: album.albumThumbnailAssetId == null
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
@@ -83,32 +112,16 @@ class AlbumThumbnailCard extends StatelessWidget {
width: cardSize,
child: Text(
album.name,
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDarkMode
? Theme.of(context).primaryColor
: Colors.black,
),
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
album.assetCount == 1
? 'album_thumbnail_card_item'
: 'album_thumbnail_card_items',
style: const TextStyle(
fontSize: 12,
),
).tr(args: ['${album.assetCount}']),
if (album.shared)
const Text(
'album_thumbnail_card_shared',
style: TextStyle(
fontSize: 12,
),
).tr()
],
)
buildAlbumTextRow(),
],
),
),

View File

@@ -2,10 +2,9 @@ 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/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -21,7 +20,6 @@ class AlbumThumbnailListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var cardSize = 68.0;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
@@ -50,8 +48,12 @@ class AlbumThumbnailListTile extends StatelessWidget {
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
httpHeaders: {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
},
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
);
}
@@ -68,7 +70,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.albumThumbnailAssetId == null
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),

View File

@@ -7,7 +7,6 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
@@ -35,19 +34,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
void onDeleteAlbumPressed() async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(albumServiceProvider).deleteAlbum(album);
if (isSuccess) {
if (album.shared) {
ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
ref.watch(albumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
}
final bool success;
if (album.shared) {
success =
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
}
if (!success) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_delete".tr(),
@@ -208,11 +206,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
: null,
centerTitle: false,
actions: [
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
if (album.isRemote)
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
],
);
}

View File

@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -22,7 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var deviceId = ref.watch(authenticationProvider).deviceId;
final selectedAssetsInAlbumViewer =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final isMultiSelectionEnable =
@@ -88,7 +86,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
bottom: 5,
child: Icon(
asset.isRemote
? (deviceId == asset.deviceId
? (asset.isLocal
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,

View File

@@ -25,7 +25,7 @@ import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegat
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
class AlbumViewerPage extends HookConsumerWidget {
final String albumId;
final int albumId;
const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key);
@@ -101,7 +101,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildTitle(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
child: userId == album.ownerId
child: userId == album.ownerId && album.isRemote
? AlbumViewerEditableTitle(
album: album,
titleFocusNode: titleFocusNode,
@@ -122,9 +122,10 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildAlbumDateRange(Album album) {
final DateTime startDate = album.assets.first.fileCreatedAt;
final DateTime endDate = album.assets.last.fileCreatedAt; //Need default.
final String startDateText =
(startDate.year == endDate.year ? DateFormat.MMMd() : DateFormat.yMMMd())
.format(startDate);
final String startDateText = (startDate.year == endDate.year
? DateFormat.MMMd()
: DateFormat.yMMMd())
.format(startDate);
final String endDateText = DateFormat.yMMMd().format(endDate);
return Padding(
@@ -188,7 +189,7 @@ class AlbumViewerPage extends HookConsumerWidget {
final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
if (album.assets.isNotEmpty) {
if (album.sortedAssets.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
@@ -201,8 +202,8 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return AlbumViewerThumbnail(
asset: album.assets[index],
assetList: album.assets,
asset: album.sortedAssets[index],
assetList: album.sortedAssets,
showStorageIndicator: showStorageIndicator,
);
},
@@ -267,18 +268,21 @@ class AlbumViewerPage extends HookConsumerWidget {
controller: scrollController,
slivers: [
buildHeader(album),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(album),
if (album.isRemote)
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(album),
),
),
),
SliverSafeArea(
sliver: buildImageGrid(album),
),
buildImageGrid(album)
],
),
),

View File

@@ -8,6 +8,8 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class LibraryPage extends HookConsumerWidget {
const LibraryPage({Key? key}) : super(key: key);
@@ -16,6 +18,7 @@ class LibraryPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider);
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
var settings = ref.watch(appSettingsServiceProvider);
useEffect(
() {
@@ -40,13 +43,17 @@ class LibraryPage extends HookConsumerWidget {
);
}
final selectedAlbumSortOrder = useState(0);
final selectedAlbumSortOrder = useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
List<Album> sortedAlbums() {
if (selectedAlbumSortOrder.value == 0) {
return albums.sortedBy((album) => album.createdAt).reversed.toList();
return albums
.where((a) => a.isRemote)
.sortedBy((album) => album.createdAt)
.reversed
.toList();
}
return albums.sortedBy((album) => album.name);
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
}
Widget buildSortButton() {
@@ -87,6 +94,7 @@ class LibraryPage extends HookConsumerWidget {
},
onSelected: (int value) {
selectedAlbumSortOrder.value = value;
settings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, value);
},
child: Row(
children: [
@@ -194,6 +202,8 @@ class LibraryPage extends HookConsumerWidget {
final sorted = sortedAlbums();
final local = albums.where((a) => a.isLocal).toList();
return Scaffold(
appBar: buildAppBar(),
body: CustomScrollView(
@@ -270,6 +280,47 @@ class LibraryPage extends HookConsumerWidget {
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'library_page_device_albums',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: local.length,
(context, index) => AlbumThumbnailCard(
album: local[index],
onTap: () => AutoRouter.of(context).push(
AlbumViewerRoute(
albumId: local[index].id,
),
),
),
),
),
),
],
),
);

View File

@@ -1,24 +1,24 @@
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';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/shared/models/store.dart' as store;
import 'package:immich_mobile/shared/ui/immich_image.dart';
class SharingPage extends HookConsumerWidget {
const SharingPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
final userId = store.Store.get(store.StoreKey.currentUser).id;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
useEffect(
() {
@@ -28,37 +28,77 @@ class SharingPage extends HookConsumerWidget {
[],
);
buildAlbumGrid() {
return SliverPadding(
padding: const EdgeInsets.all(18.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return AlbumThumbnailCard(
album: sharedAlbums[index],
showOwner: true,
onTap: () {
AutoRouter.of(context)
.push(AlbumViewerRoute(albumId: sharedAlbums[index].id));
},
);
},
childCount: sharedAlbums.length,
),
),
);
}
buildAlbumList() {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final album = sharedAlbums[index];
final isOwner = album.ownerId == userId;
return ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
child: ImmichImage(
album.thumbnail.value,
width: 60,
height: 60,
fit: BoxFit.cover,
imageUrl: getAlbumThumbnailUrl(album),
cacheKey: getAlbumThumbNailCacheKey(album),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 200),
),
),
title: Text(
sharedAlbums[index].name,
album.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: isDarkMode
? Theme.of(context).primaryColor
: Colors.black,
),
),
subtitle: isOwner
? const Text(
'Owned',
style: TextStyle(
fontSize: 12.0,
),
)
: album.ownerName != null
? Text(
'Shared by ${album.ownerName!}',
style: const TextStyle(
fontSize: 12.0,
),
)
: null,
onTap: () {
AutoRouter.of(context)
.push(AlbumViewerRoute(albumId: sharedAlbums[index].id));
@@ -134,9 +174,19 @@ class SharingPage extends HookConsumerWidget {
).tr(),
),
),
sharedAlbums.isNotEmpty
? buildAlbumList()
: buildEmptyListIndication()
SliverLayoutBuilder(
builder: (context, constraints) {
if (sharedAlbums.isEmpty) {
return buildEmptyListIndication();
}
if (constraints.crossAxisExtent < 600) {
return buildAlbumList();
} else {
return buildAlbumGrid();
}
},
),
],
),
);

View File

@@ -0,0 +1,17 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
final renderListProvider = FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
var settings = ref.watch(appSettingsServiceProvider);
final layout = AssetGridLayoutParameters(
settings.getSetting(AppSettingsEnum.tilesPerRow),
settings.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
);
return RenderList.fromAssets(assets, layout);
});

View File

@@ -14,10 +14,14 @@ class ExifBottomSheet extends HookConsumerWidget {
const ExifBottomSheet({Key? key, required this.assetDetail})
: super(key: key);
bool get showMap => assetDetail.latitude != null && assetDetail.longitude != null;
bool get showMap =>
assetDetail.exifInfo?.latitude != null &&
assetDetail.exifInfo?.longitude != null;
@override
Widget build(BuildContext context, WidgetRef ref) {
final ExifInfo? exifInfo = assetDetail.exifInfo;
buildMap() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
@@ -33,8 +37,8 @@ class ExifBottomSheet extends HookConsumerWidget {
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: LatLng(
assetDetail.latitude ?? 0,
assetDetail.longitude ?? 0,
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
zoom: 16.0,
),
@@ -55,8 +59,8 @@ class ExifBottomSheet extends HookConsumerWidget {
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
assetDetail.latitude ?? 0,
assetDetail.longitude ?? 0,
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
@@ -74,8 +78,6 @@ class ExifBottomSheet extends HookConsumerWidget {
final textColor = Theme.of(context).primaryColor;
ExifInfo? exifInfo = assetDetail.exifInfo;
buildLocationText() {
return Text(
"${exifInfo?.city}, ${exifInfo?.state}",
@@ -134,7 +136,7 @@ class ExifBottomSheet extends HookConsumerWidget {
exifInfo.state != null)
buildLocationText(),
Text(
"${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12),
)
],

View File

@@ -4,20 +4,20 @@ import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
@@ -38,8 +38,7 @@ class GalleryViewerPage extends HookConsumerWidget {
super.key,
required this.assetList,
required this.asset,
}) : controller =
PageController(initialPage: assetList.indexOf(asset));
}) : controller = PageController(initialPage: assetList.indexOf(asset));
Asset? assetDetail;
@@ -47,7 +46,6 @@ class GalleryViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final Box<dynamic> box = Hive.box(userInfoBox);
final settings = ref.watch(appSettingsServiceProvider);
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
@@ -57,7 +55,16 @@ class GalleryViewerPage extends HookConsumerWidget {
final isPlayingMotionVideo = useState(false);
final isPlayingVideo = useState(false);
late Offset localPosition;
final authToken = 'Bearer ${box.get(accessTokenKey)}';
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
showAppBar.addListener(() {
// Change to and from immersive mode, hiding navigation and app bar
if (showAppBar.value) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
});
useEffect(
() {
@@ -75,15 +82,11 @@ class GalleryViewerPage extends HookConsumerWidget {
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
}
getAssetExif() async {
if (assetList[indexOfAsset.value].isRemote) {
assetDetail = await ref
.watch(assetServiceProvider)
.getAssetById(assetList[indexOfAsset.value].id);
} else {
// TODO local exif parsing?
assetDetail = assetList[indexOfAsset.value];
}
void getAssetExif() async {
assetDetail = assetList[indexOfAsset.value];
assetDetail = await ref
.watch(assetServiceProvider)
.loadExif(assetList[indexOfAsset.value]);
}
/// Thumbnail image of a remote asset. Required asset.isRemote
@@ -127,16 +130,23 @@ class GalleryViewerPage extends HookConsumerWidget {
/// Original (large) image of a local asset. Required asset.isLocal
ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(asset.local!);
return AssetEntityImageProvider(
isOriginal: true,
asset.local!,
);
}
void precacheNextImage(int index) {
if (index < assetList.length && index > 0) {
if (index < assetList.length && index >= 0) {
final asset = assetList[index];
if (asset.isLocal) {
// Preload the local asset
precacheImage(localImageProvider(asset), context);
} else {
onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
}
// Probably load WEBP either way
precacheImage(
remoteThumbnailImageProvider(
@@ -144,6 +154,7 @@ class GalleryViewerPage extends HookConsumerWidget {
api.ThumbnailFormat.WEBP,
),
context,
onError: onError,
);
if (isLoadPreview.value) {
// Precache the JPEG thumbnail
@@ -153,6 +164,7 @@ class GalleryViewerPage extends HookConsumerWidget {
api.ThumbnailFormat.JPEG,
),
context,
onError: onError,
);
}
if (isLoadOriginal.value) {
@@ -160,6 +172,7 @@ class GalleryViewerPage extends HookConsumerWidget {
precacheImage(
originalImageProvider(asset),
context,
onError: onError,
);
}
}
@@ -292,140 +305,173 @@ class GalleryViewerPage extends HookConsumerWidget {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PhotoViewGallery.builder(
scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial;
showAppBar.value = !isZoomed.value;
},
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value
? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(
asset,
type: api.ThumbnailFormat.WEBP,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.WEBP,
),
httpHeaders: {'Authorization': authToken},
progressIndicatorBuilder: (_, __, ___) => const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
);
body: WillPopScope(
onWillPop: () async {
// Change immersive mode back to normal "edgeToEdge" mode
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
return true;
},
child: Stack(
children: [
PhotoViewGallery.builder(
scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial;
showAppBar.value = !isZoomed.value;
},
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value
? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(
asset,
type: api.ThumbnailFormat.WEBP,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.WEBP,
),
httpHeaders: {'Authorization': authToken},
progressIndicatorBuilder: (_, __, ___) =>
const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
);
return CachedNetworkImage(
imageUrl: getThumbnailUrl(
asset,
type: api.ThumbnailFormat.JPEG,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.JPEG,
),
httpHeaders: {'Authorization': authToken},
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
if (isLoadOriginal.value) {
// loading the preview in the loadingBuilder only
// makes sense if the original is loaded in the builder
return CachedNetworkImage(
imageUrl: getThumbnailUrl(
asset,
type: api.ThumbnailFormat.JPEG,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.JPEG,
),
httpHeaders: {'Authorization': authToken},
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
errorWidget: (_, __, ___) => webPThumbnail,
);
} else {
return webPThumbnail;
}
} else {
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
}
: null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else if (isLoadPreview.value) {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
} else {
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.WEBP,
);
}
}
: null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
imageProvider: provider,
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
maxScale: 1.0,
minScale: 1.0,
child: SafeArea(
child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false,
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(
tag: assetList[index].id,
),
),
);
}
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: buildAppBar(),
),
],
filterQuality: FilterQuality.high,
tightMode: true,
minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage(
assetList[indexOfAsset.value],
fit: BoxFit.contain,
),
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: assetList[index].id,
),
filterQuality: FilterQuality.high,
maxScale: 1.0,
minScale: 1.0,
child: SafeArea(
child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false,
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
),
),
);
}
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: buildAppBar(),
),
],
),
),
);
}

View File

@@ -1,13 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:chewie/chewie.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
@@ -54,17 +53,15 @@ class VideoViewerPage extends HookConsumerWidget {
}
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
final box = Hive.box(userInfoBox);
final String jwtToken = box.get(accessTokenKey);
final String videoUrl = isMotionVideo
? '${box.get(serverEndpointKey)}/asset/file/${asset.livePhotoVideoId}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}';
? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
: '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
return Stack(
children: [
VideoThumbnailPlayer(
url: videoUrl,
jwtToken: jwtToken,
jwtToken: Store.get(StoreKey.accessToken),
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded,
onPaused: onPaused,

View File

@@ -4,21 +4,22 @@ import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.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/main.dart';
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/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/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -51,10 +52,6 @@ class BackgroundService {
_Throttle(_updateProgress, notifyInterval);
late final _Throttle _throttledDetailNotify =
_Throttle(_updateDetailProgress, notifyInterval);
Completer<bool> _hasAccessCompleter = Completer();
late Future<bool> _hasAccess = _hasAccessCompleter.future;
Future<bool> get hasAccess => _hasAccess;
bool get isBackgroundInitialized {
return _isBackgroundInitialized;
@@ -194,11 +191,6 @@ class BackgroundService {
debugPrint("WARNING: [acquireLock] called more than once");
return true;
}
if (_hasAccessCompleter.isCompleted) {
debugPrint("WARNING: [acquireLock] _hasAccessCompleter is completed");
_hasAccessCompleter = Completer();
_hasAccess = _hasAccessCompleter.future;
}
final int lockTime = Timeline.now;
_wantsLockTime = lockTime;
final ReceivePort rp = ReceivePort(_portNameLock);
@@ -217,7 +209,6 @@ class BackgroundService {
}
_hasLock = true;
rp.listen(_heartbeatListener);
_hasAccessCompleter.complete(true);
return true;
}
@@ -267,8 +258,6 @@ class BackgroundService {
void releaseLock() {
_wantsLockTime = 0;
if (_hasLock) {
_hasAccessCompleter = Completer();
_hasAccess = _hasAccessCompleter.future;
IsolateNameServer.removePortNameMapping(_portNameLock);
_waitingIsolate?.send(true);
_waitingIsolate = null;
@@ -325,7 +314,6 @@ class BackgroundService {
debugPrint(error.toString());
return false;
} finally {
await Hive.close();
releaseLock();
}
case "systemStop":
@@ -339,29 +327,16 @@ class BackgroundService {
}
Future<bool> _onAssetsChanged() async {
await Hive.initFlutter();
final Isar db = await loadDb();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
await Future.wait([
Hive.openBox(userInfoBox),
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
Hive.openBox(userSettingInfoBox),
Hive.openBox(backgroundBackupInfoBox),
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
]);
ApiService apiService = ApiService();
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
BackupService backupService = BackupService(apiService);
apiService.setAccessToken(Store.get(StoreKey.accessToken));
BackupService backupService = BackupService(apiService, db);
AppSettingsService settingsService = AppSettingsService();
final Box<HiveBackupAlbums> box =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
if (backupAlbumInfo == null) {
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
if (selectedAlbums.isEmpty) {
return true;
}
@@ -371,18 +346,37 @@ class BackgroundService {
final bool backupOk = await _runBackup(
backupService,
settingsService,
backupAlbumInfo,
selectedAlbums,
excludedAlbums,
);
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());
await Store.delete(StoreKey.backupFailedSince);
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
backupAlbums.sortBy((e) => e.id);
db.writeTxnSync(() {
final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
diffSortedListsSync(
dbAlbums,
backupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
? a.lastBackup
: b.lastBackup;
toUpsert.add(a);
return true;
},
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
);
db.backupAlbums.deleteAllSync(toDelete);
db.backupAlbums.putAllSync(toUpsert);
});
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
Store.put(StoreKey.backupFailedSince, DateTime.now());
return false;
}
// Android should check for new assets added while performing backup
@@ -395,7 +389,8 @@ class BackgroundService {
Future<bool> _runBackup(
BackupService backupService,
AppSettingsService settingsService,
HiveBackupAlbums backupAlbumInfo,
List<BackupAlbum> selectedAlbums,
List<BackupAlbum> excludedAlbums,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
final bool notifyTotalProgress = settingsService
@@ -407,8 +402,10 @@ class BackgroundService {
return false;
}
List<AssetEntity> toUpload =
await backupService.buildUploadCandidates(backupAlbumInfo);
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
selectedAlbums,
excludedAlbums,
);
try {
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
@@ -520,8 +517,7 @@ class BackgroundService {
} else if (value == 5) {
return false;
}
final DateTime? failedSince =
Hive.box(backgroundBackupInfoBox).get(backupFailedSince);
final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
if (failedSince == null) {
return false;
}
@@ -560,6 +556,9 @@ class BackgroundService {
}
Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
if (!Platform.isIOS) {
return null;
}
// Seconds since last run
final double? lastRun = task == IosBackgroundTask.fetch
? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime')
@@ -572,10 +571,16 @@ class BackgroundService {
}
Future<int> getIOSBackupNumberOfProcesses() async {
if (!Platform.isIOS) {
return 0;
}
return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
}
Future<bool> getIOSBackgroundAppRefreshEnabled() async {
if (!Platform.isIOS) {
return false;
}
return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
}
}

View File

@@ -0,0 +1,22 @@
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
part 'backup_album.model.g.dart';
@Collection(inheritance: false)
class BackupAlbum {
String id;
DateTime lastBackup;
@Enumerated(EnumType.ordinal)
BackupSelection selection;
BackupAlbum(this.id, this.lastBackup, this.selection);
Id get isarId => fastHash(id);
}
enum BackupSelection {
none,
select,
exclude;
}

View File

@@ -0,0 +1,653 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_album.model.dart';
// **************************************************************************
// IsarCollectionGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters
extension GetBackupAlbumCollection on Isar {
IsarCollection<BackupAlbum> get backupAlbums => this.collection();
}
const BackupAlbumSchema = CollectionSchema(
name: r'BackupAlbum',
id: 8308487201128361847,
properties: {
r'id': PropertySchema(
id: 0,
name: r'id',
type: IsarType.string,
),
r'lastBackup': PropertySchema(
id: 1,
name: r'lastBackup',
type: IsarType.dateTime,
),
r'selection': PropertySchema(
id: 2,
name: r'selection',
type: IsarType.byte,
enumMap: _BackupAlbumselectionEnumValueMap,
)
},
estimateSize: _backupAlbumEstimateSize,
serialize: _backupAlbumSerialize,
deserialize: _backupAlbumDeserialize,
deserializeProp: _backupAlbumDeserializeProp,
idName: r'isarId',
indexes: {},
links: {},
embeddedSchemas: {},
getId: _backupAlbumGetId,
getLinks: _backupAlbumGetLinks,
attach: _backupAlbumAttach,
version: '3.0.5',
);
int _backupAlbumEstimateSize(
BackupAlbum object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
bytesCount += 3 + object.id.length * 3;
return bytesCount;
}
void _backupAlbumSerialize(
BackupAlbum object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeString(offsets[0], object.id);
writer.writeDateTime(offsets[1], object.lastBackup);
writer.writeByte(offsets[2], object.selection.index);
}
BackupAlbum _backupAlbumDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = BackupAlbum(
reader.readString(offsets[0]),
reader.readDateTime(offsets[1]),
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
BackupSelection.none,
);
return object;
}
P _backupAlbumDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readString(offset)) as P;
case 1:
return (reader.readDateTime(offset)) as P;
case 2:
return (_BackupAlbumselectionValueEnumMap[
reader.readByteOrNull(offset)] ??
BackupSelection.none) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
const _BackupAlbumselectionEnumValueMap = {
'none': 0,
'select': 1,
'exclude': 2,
};
const _BackupAlbumselectionValueEnumMap = {
0: BackupSelection.none,
1: BackupSelection.select,
2: BackupSelection.exclude,
};
Id _backupAlbumGetId(BackupAlbum object) {
return object.isarId;
}
List<IsarLinkBase<dynamic>> _backupAlbumGetLinks(BackupAlbum object) {
return [];
}
void _backupAlbumAttach(
IsarCollection<dynamic> col, Id id, BackupAlbum object) {}
extension BackupAlbumQueryWhereSort
on QueryBuilder<BackupAlbum, BackupAlbum, QWhere> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhere> anyIsarId() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension BackupAlbumQueryWhere
on QueryBuilder<BackupAlbum, BackupAlbum, QWhereClause> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdEqualTo(
Id isarId) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: isarId,
upper: isarId,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdNotEqualTo(
Id isarId) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
);
}
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdGreaterThan(
Id isarId,
{bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: include),
);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdLessThan(
Id isarId,
{bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: include),
);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdBetween(
Id lowerIsarId,
Id upperIsarId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerIsarId,
includeLower: includeLower,
upper: upperIsarId,
includeUpper: includeUpper,
));
});
}
}
extension BackupAlbumQueryFilter
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEqualTo(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idGreaterThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idLessThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idBetween(
String lower,
String upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'id',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'id',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: '',
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'id',
value: '',
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdEqualTo(
Id value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'isarId',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
isarIdGreaterThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'isarId',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdLessThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'isarId',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdBetween(
Id lower,
Id upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'isarId',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupEqualTo(DateTime value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupGreaterThan(
DateTime value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupLessThan(
DateTime value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupBetween(
DateTime lower,
DateTime upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'lastBackup',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectionEqualTo(BackupSelection value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'selection',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectionGreaterThan(
BackupSelection value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'selection',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectionLessThan(
BackupSelection value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'selection',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectionBetween(
BackupSelection lower,
BackupSelection upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'selection',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
}
extension BackupAlbumQueryObject
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
extension BackupAlbumQueryLinks
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
extension BackupAlbumQuerySortBy
on QueryBuilder<BackupAlbum, BackupAlbum, QSortBy> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackupDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'selection', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelectionDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'selection', Sort.desc);
});
}
}
extension BackupAlbumQuerySortThenBy
on QueryBuilder<BackupAlbum, BackupAlbum, QSortThenBy> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIsarId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIsarIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackupDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'selection', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelectionDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'selection', Sort.desc);
});
}
}
extension BackupAlbumQueryWhereDistinct
on QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> {
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctById(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'lastBackup');
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'selection');
});
}
}
extension BackupAlbumQueryProperty
on QueryBuilder<BackupAlbum, BackupAlbum, QQueryProperty> {
QueryBuilder<BackupAlbum, int, QQueryOperations> isarIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isarId');
});
}
QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
});
}
QueryBuilder<BackupAlbum, DateTime, QQueryOperations> lastBackupProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'lastBackup');
});
}
QueryBuilder<BackupAlbum, BackupSelection, QQueryOperations>
selectionProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'selection');
});
}
}

View File

@@ -15,6 +15,7 @@ class BackUpState {
final double progressInPercentage;
final CancellationToken cancelToken;
final ServerInfoResponseDto serverInfo;
final bool autoBackup;
final bool backgroundBackup;
final bool backupRequireWifi;
final bool backupRequireCharging;
@@ -40,6 +41,7 @@ class BackUpState {
required this.progressInPercentage,
required this.cancelToken,
required this.serverInfo,
required this.autoBackup,
required this.backgroundBackup,
required this.backupRequireWifi,
required this.backupRequireCharging,
@@ -58,6 +60,7 @@ class BackUpState {
double? progressInPercentage,
CancellationToken? cancelToken,
ServerInfoResponseDto? serverInfo,
bool? autoBackup,
bool? backgroundBackup,
bool? backupRequireWifi,
bool? backupRequireCharging,
@@ -75,6 +78,7 @@ class BackUpState {
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
backupRequireCharging:
@@ -92,7 +96,7 @@ class BackUpState {
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
}
@override
@@ -106,6 +110,7 @@ class BackUpState {
other.progressInPercentage == progressInPercentage &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup &&
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
@@ -128,6 +133,7 @@ class BackUpState {
progressInPercentage.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
autoBackup.hashCode ^
backgroundBackup.hashCode ^
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^

View File

@@ -0,0 +1,11 @@
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
part 'duplicated_asset.model.g.dart';
@Collection(inheritance: false)
class DuplicatedAsset {
String id;
DuplicatedAsset(this.id);
Id get isarId => fastHash(id);
}

View File

@@ -0,0 +1,443 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'duplicated_asset.model.dart';
// **************************************************************************
// IsarCollectionGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters
extension GetDuplicatedAssetCollection on Isar {
IsarCollection<DuplicatedAsset> get duplicatedAssets => this.collection();
}
const DuplicatedAssetSchema = CollectionSchema(
name: r'DuplicatedAsset',
id: -2679334728174694496,
properties: {
r'id': PropertySchema(
id: 0,
name: r'id',
type: IsarType.string,
)
},
estimateSize: _duplicatedAssetEstimateSize,
serialize: _duplicatedAssetSerialize,
deserialize: _duplicatedAssetDeserialize,
deserializeProp: _duplicatedAssetDeserializeProp,
idName: r'isarId',
indexes: {},
links: {},
embeddedSchemas: {},
getId: _duplicatedAssetGetId,
getLinks: _duplicatedAssetGetLinks,
attach: _duplicatedAssetAttach,
version: '3.0.5',
);
int _duplicatedAssetEstimateSize(
DuplicatedAsset object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
bytesCount += 3 + object.id.length * 3;
return bytesCount;
}
void _duplicatedAssetSerialize(
DuplicatedAsset object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeString(offsets[0], object.id);
}
DuplicatedAsset _duplicatedAssetDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = DuplicatedAsset(
reader.readString(offsets[0]),
);
return object;
}
P _duplicatedAssetDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readString(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _duplicatedAssetGetId(DuplicatedAsset object) {
return object.isarId;
}
List<IsarLinkBase<dynamic>> _duplicatedAssetGetLinks(DuplicatedAsset object) {
return [];
}
void _duplicatedAssetAttach(
IsarCollection<dynamic> col, Id id, DuplicatedAsset object) {}
extension DuplicatedAssetQueryWhereSort
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhere> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhere> anyIsarId() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension DuplicatedAssetQueryWhere
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhereClause> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdEqualTo(Id isarId) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: isarId,
upper: isarId,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdNotEqualTo(Id isarId) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
);
}
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdGreaterThan(Id isarId, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: include),
);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdLessThan(Id isarId, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: include),
);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdBetween(
Id lowerIsarId,
Id upperIsarId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerIsarId,
includeLower: includeLower,
upper: upperIsarId,
includeUpper: includeUpper,
));
});
}
}
extension DuplicatedAssetQueryFilter
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idEqualTo(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idGreaterThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idLessThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idBetween(
String lower,
String upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'id',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'id',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: '',
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'id',
value: '',
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
isarIdEqualTo(Id value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'isarId',
value: value,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
isarIdGreaterThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'isarId',
value: value,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
isarIdLessThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'isarId',
value: value,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
isarIdBetween(
Id lower,
Id upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'isarId',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
}
extension DuplicatedAssetQueryObject
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
extension DuplicatedAssetQueryLinks
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
extension DuplicatedAssetQuerySortBy
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortBy> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
}
extension DuplicatedAssetQuerySortThenBy
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortThenBy> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIsarId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.asc);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy>
thenByIsarIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.desc);
});
}
}
extension DuplicatedAssetQueryWhereDistinct
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> distinctById(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
});
}
}
extension DuplicatedAssetQueryProperty
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QQueryProperty> {
QueryBuilder<DuplicatedAsset, int, QQueryOperations> isarIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isarId');
});
}
QueryBuilder<DuplicatedAsset, String, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
});
}
}

View File

@@ -1,23 +1,27 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.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/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> {
@@ -26,6 +30,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._serverInfoService,
this._authState,
this._backgroundService,
this._galleryPermissionNotifier,
this._db,
this.ref,
) : super(
BackUpState(
@@ -33,10 +39,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
allAssetsInDatabase: const [],
progressInPercentage: 0,
cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: false,
backupRequireWifi: true,
backupRequireCharging: false,
backupTriggerDelay: 5000,
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
backupRequireCharging:
Store.get(StoreKey.backupRequireCharging, false),
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
serverInfo: ServerInfoResponseDto(
diskAvailable: "0",
diskAvailableRaw: 0,
@@ -65,6 +73,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final ServerInfoService _serverInfoService;
final AuthenticationState _authState;
final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db;
final Ref ref;
///
@@ -112,6 +122,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void setAutoBackup(bool enabled) {
Store.put(StoreKey.autoBackup, enabled);
state = state.copyWith(autoBackup: enabled);
}
void configureBackgroundBackup({
bool? enabled,
bool? requireWifi,
@@ -153,12 +168,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
triggerMaxDelay: state.backupTriggerDelay * 10,
);
if (success) {
final box = Hive.box(backgroundBackupInfoBox);
await Future.wait([
box.put(backupRequireWifi, state.backupRequireWifi),
box.put(backupRequireCharging, state.backupRequireCharging),
box.put(backupTriggerDelay, state.backupTriggerDelay),
]);
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
await Store.put(
StoreKey.backupRequireCharging,
state.backupRequireCharging,
);
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
@@ -197,16 +212,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
var assetCountInAlbum = await album.assetCountAsync;
final assetCountInAlbum = await album.assetCountAsync;
if (assetCountInAlbum > 0) {
var assetList =
final assetList =
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first;
final thumbnailAsset = assetList.first;
try {
var thumbnailData = await thumbnailAsset
final thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
@@ -225,34 +240,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(availableAlbums: availableAlbums);
// Put persistent storage info into local state of the app
// Get local storage on selected backup album
Box<HiveBackupAlbums> backupAlbumInfoBox =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
backupInfoKey,
defaultValue: HiveBackupAlbums(
selectedAlbumIds: [],
excludedAlbumsIds: [],
lastSelectedBackupTime: [],
lastExcludedBackupTime: [],
),
);
if (backupAlbumInfo == null) {
log.severe(
"backupAlbumInfo == null",
"Failed to get Hive backup album information",
);
return;
}
final List<BackupAlbum> excludedBackupAlbums =
await _backupService.excludedAlbumsQuery().findAll();
final List<BackupAlbum> selectedBackupAlbums =
await _backupService.selectedAlbumsQuery().findAll();
// First time backup - set isAll album is the default one for backup.
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
if (selectedBackupAlbums.isEmpty) {
log.info("First time backup; setup 'Recent(s)' album as default");
// Get album that contains all assets
var list = await PhotoManager.getAssetPathList(
final list = await PhotoManager.getAssetPathList(
hasAll: true,
onlyAll: true,
type: RequestType.common,
@@ -263,48 +261,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
AssetPathEntity albumHasAllAssets = list.first;
backupAlbumInfoBox.put(
backupInfoKey,
HiveBackupAlbums(
selectedAlbumIds: [albumHasAllAssets.id],
excludedAlbumsIds: [],
lastSelectedBackupTime: [
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)
],
lastExcludedBackupTime: [],
),
final ba = BackupAlbum(
albumHasAllAssets.id,
DateTime.fromMillisecondsSinceEpoch(0),
BackupSelection.select,
);
backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey);
await _db.writeTxn(() => _db.backupAlbums.put(ba));
}
// Generate AssetPathEntity from id to add to local state
try {
Set<AvailableAlbum> selectedAlbums = {};
for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) {
var albumAsset =
await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]);
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = await AssetPathEntity.fromId(ba.id);
selectedAlbums.add(
AvailableAlbum(
albumEntity: albumAsset,
lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i
? backupAlbumInfo.lastSelectedBackupTime[i]
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
),
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
}
Set<AvailableAlbum> excludedAlbums = {};
for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) {
var albumAsset =
await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]);
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = await AssetPathEntity.fromId(ba.id);
excludedAlbums.add(
AvailableAlbum(
albumEntity: albumAsset,
lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i
? backupAlbumInfo.lastExcludedBackupTime[i]
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
),
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
}
state = state.copyWith(
@@ -324,36 +303,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Those assets are unique and are used as the total assets
///
Future<void> _updateBackupAssetCount() async {
Set<String> duplicatedAssetIds = _backupService.getDuplicatedAssetIds();
Set<AssetEntity> assetsFromSelectedAlbums = {};
Set<AssetEntity> assetsFromExcludedAlbums = {};
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<AssetEntity> assetsFromSelectedAlbums = {};
final Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) {
var assets = await album.albumEntity.getAssetListRange(
for (final album in state.selectedBackupAlbums) {
final 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.albumEntity.getAssetListRange(
for (final album in state.excludedBackupAlbums) {
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets);
}
Set<AssetEntity> allUniqueAssets =
final Set<AssetEntity> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
var allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return;
}
// Find asset that were backup from selected albums
Set<String> selectedAlbumsBackupAssets =
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets
@@ -382,7 +361,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
// Save to persistent storage
_updatePersistentAlbumsSelection();
await _updatePersistentAlbumsSelection();
return;
}
@@ -391,7 +370,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// which albums are selected or excluded
/// and then update the UI according to those information
Future<void> getBackupInfo() async {
var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
final isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
@@ -402,25 +381,38 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
/// Save user selection of selected albums and excluded albums to
/// Hive database
void _updatePersistentAlbumsSelection() {
/// Save user selection of selected albums and excluded albums to database
Future<void> _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
Box<HiveBackupAlbums> backupAlbumInfoBox =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
backupAlbumInfoBox.put(
backupInfoKey,
HiveBackupAlbums(
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
lastSelectedBackupTime: state.selectedBackupAlbums
.map((e) => e.lastBackup ?? epoch)
.toList(),
lastExcludedBackupTime: state.excludedBackupAlbums
.map((e) => e.lastBackup ?? epoch)
.toList(),
),
final selected = state.selectedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
);
final excluded = state.excludedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
);
final backupAlbums = selected.followedBy(excluded).toList();
backupAlbums.sortBy((e) => e.id);
return _db.writeTxn(() async {
final dbAlbums = await _db.backupAlbums.where().sortById().findAll();
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made
diffSortedListsSync(
dbAlbums,
backupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
b.lastBackup =
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
toUpsert.add(b);
return true;
},
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
onlySecond: (BackupAlbum b) => toUpsert.add(b),
);
await _db.backupAlbums.deleteAll(toDelete);
await _db.backupAlbums.putAll(toUpsert);
});
}
/// Invoke backup process
@@ -431,8 +423,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await getBackupInfo();
var authResult = await PhotoManager.requestPermissionExtend();
if (authResult.isAuth) {
final hasPermission = _galleryPermissionNotifier.hasPermission;
if (hasPermission) {
await PhotoManager.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
@@ -443,7 +435,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (var assetId in state.allAssetsInDatabase) {
for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
}
@@ -463,7 +455,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
await _notifyBackgroundServiceCanRun();
} else {
PhotoManager.openSetting();
openAppSettings();
}
}
@@ -543,7 +535,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
Future<void> _updateServerInfo() async {
var serverInfo = await _serverInfoService.getServerInfo();
final serverInfo = await _serverInfoService.getServerInfo();
// Update server info
if (serverInfo != null) {
@@ -555,7 +547,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Future<void> _resumeBackup() async {
// Check if user is login
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
final accessKey = Store.tryGet(StoreKey.accessToken);
// User has been logged out return
if (accessKey == null || !_authState.isAuthenticated) {
@@ -564,8 +556,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
// Check if this device is enable backup by the user
if ((_authState.deviceInfo.deviceId == _authState.deviceId) &&
_authState.deviceInfo.isAutoBackup) {
if (state.autoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
log.info("[_resumeBackup] Backup is already in progress - abort");
@@ -581,70 +572,57 @@ class BackupNotifier extends StateNotifier<BackUpState> {
log.info("[_resumeBackup] Start back up");
await startBackupProcess();
}
return;
}
Future<void> resumeBackup() async {
// 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) {
log.warning("WARNING [resumeBackup] failed to acquireLock");
return;
}
await Future.wait([
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
Hive.openBox(backgroundBackupInfoBox),
]);
final HiveBackupAlbums? albums =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey);
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.findAll();
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.findAll();
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (albums != null) {
if (selectedAlbums.isNotEmpty) {
selectedAlbums = _updateAlbumsBackupTime(
selectedAlbums,
albums.selectedAlbumIds,
albums.lastSelectedBackupTime,
);
}
if (excludedAlbums.isNotEmpty) {
excludedAlbums = _updateAlbumsBackupTime(
excludedAlbums,
albums.excludedAlbumsIds,
albums.lastExcludedBackupTime,
);
}
if (selectedAlbums.isNotEmpty) {
selectedAlbums = _updateAlbumsBackupTime(
selectedAlbums,
selectedBackupAlbums,
);
}
final Box backgroundBox = Hive.box(backgroundBackupInfoBox);
if (excludedAlbums.isNotEmpty) {
excludedAlbums = _updateAlbumsBackupTime(
excludedAlbums,
excludedBackupAlbums,
);
}
final BackUpProgressEnum previous = state.backupProgress;
state = state.copyWith(
backupProgress: previous,
backupProgress: BackUpProgressEnum.inBackground,
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
backupRequireWifi: backgroundBox.get(backupRequireWifi),
backupRequireCharging: backgroundBox.get(backupRequireCharging),
backupTriggerDelay: backgroundBox.get(backupTriggerDelay),
);
// assumes the background service is currently running
// if true, waits until it has stopped to start the backup
final bool hasLock = await _backgroundService.acquireLock();
if (hasLock) {
state = state.copyWith(backupProgress: previous);
}
return _resumeBackup();
}
Set<AvailableAlbum> _updateAlbumsBackupTime(
Set<AvailableAlbum> albums,
List<String> ids,
List<DateTime> times,
List<BackupAlbum> backupAlbums,
) {
Set<AvailableAlbum> result = {};
for (int i = 0; i < ids.length; i++) {
for (BackupAlbum ba in backupAlbums) {
try {
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
result.add(a.copyWith(lastBackup: times[i]));
AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id);
result.add(a.copyWith(lastBackup: ba.lastBackup));
} on StateError {
log.severe(
"[_updateAlbumBackupTime] failed to find album in state",
@@ -663,35 +641,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
AppStateEnum.detached,
];
if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
try {
if (Hive.isBoxOpen(hiveBackupInfoBox)) {
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
}
} catch (error) {
log.info("[_notifyBackgroundServiceCanRun] failed to close box");
}
try {
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
}
} catch (error, stackTrace) {
log.severe(
"[_notifyBackgroundServiceCanRun] failed to close box",
error,
stackTrace,
);
}
try {
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
await Hive.box(backgroundBackupInfoBox).close();
}
} catch (error, stackTrace) {
log.severe(
"[_notifyBackgroundServiceCanRun] failed to close box",
error,
stackTrace,
);
}
_backgroundService.releaseLock();
}
}
@@ -704,6 +653,8 @@ final backupProvider =
ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider),
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(dbProvider),
ref,
);
});

View File

@@ -5,37 +5,39 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/shared/models/store.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/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http;
import '../models/hive_duplicated_assets.model.dart';
final backupServiceProvider = Provider(
(ref) => BackupService(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
),
);
class BackupService {
final httpClient = http.Client();
final ApiService _apiService;
final Isar _db;
BackupService(this._apiService);
BackupService(this._apiService, this._db);
Future<List<String>?> getDeviceBackupAsset() async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final String deviceId = Store.get(StoreKey.deviceId);
try {
return await _apiService.assetApi.getUserAssetsByDeviceId(deviceId);
@@ -45,32 +47,28 @@ class BackupService {
}
}
void _saveDuplicatedAssetIdToLocalStorage(List<String> deviceAssetIds) {
HiveDuplicatedAssets duplicatedAssets =
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.get(duplicatedAssetsKey) ??
HiveDuplicatedAssets(duplicatedAssetIds: []);
duplicatedAssets.duplicatedAssetIds =
{...duplicatedAssets.duplicatedAssetIds, ...deviceAssetIds}.toList();
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.put(duplicatedAssetsKey, duplicatedAssets);
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
}
/// Get duplicated asset id from Hive storage
Set<String> getDuplicatedAssetIds() {
HiveDuplicatedAssets duplicatedAssets =
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.get(duplicatedAssetsKey) ??
HiveDuplicatedAssets(duplicatedAssetIds: []);
return duplicatedAssets.duplicatedAssetIds.toSet();
/// Get duplicated asset id from database
Future<Set<String>> getDuplicatedAssetIds() async {
final duplicates = await _db.duplicatedAssets.where().findAll();
return duplicates.map((e) => e.id).toSet();
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectedAlbumsQuery() =>
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
excludedAlbumsQuery() =>
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
/// Returns all assets newer than the last successful backup per album
Future<List<AssetEntity>> buildUploadCandidates(
HiveBackupAlbums backupAlbums,
List<BackupAlbum> selectedBackupAlbums,
List<BackupAlbum> excludedBackupAlbums,
) async {
final filter = FilterOptionGroup(
containsPathModified: true,
@@ -81,66 +79,55 @@ class BackupService {
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(
backupAlbums.selectedAlbumIds,
backupAlbums.lastSelectedBackupTime,
filter,
now,
);
await _loadAlbumsWithTimeFilter(selectedBackupAlbums, 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,
);
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums.slice(allIdx, allIdx + 1),
backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1),
selectedBackupAlbums.slice(allIdx, allIdx + 1),
now,
);
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
backupAlbums.lastExcludedBackupTime,
excludedBackupAlbums,
now,
);
return toAdd.toSet().difference(toRemove.toSet()).toList();
} else {
return await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
backupAlbums.lastSelectedBackupTime,
selectedBackupAlbums,
now,
);
}
}
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<String> albumIds,
List<DateTime> lastBackups,
List<BackupAlbum> albums,
FilterOptionGroup filter,
DateTime now,
) async {
List<AssetPathEntity?> result = List.filled(albumIds.length, null);
for (int i = 0; i < albumIds.length; i++) {
List<AssetPathEntity?> result = [];
for (BackupAlbum a in albums) {
try {
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: albumIds[i],
id: a.id,
optionGroup: filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: lastBackups[i].subtract(const Duration(seconds: 2)),
min: a.lastBackup.subtract(const Duration(seconds: 2)),
max: now,
),
),
maxDateTimeToNow: false,
);
result[i] = album;
result.add(album);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
@@ -150,17 +137,18 @@ class BackupService {
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> albums,
List<DateTime> lastBackup,
List<BackupAlbum> backupAlbums,
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) {
if (a != null &&
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
result.addAll(
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
);
lastBackup[i] = now;
backupAlbums[i].lastBackup = now;
}
}
return result;
@@ -173,7 +161,7 @@ class BackupService {
if (candidates.isEmpty) {
return candidates;
}
final Set<String> duplicatedAssetIds = getDuplicatedAssetIds();
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
candidates = duplicatedAssetIds.isEmpty
? candidates
: candidates
@@ -184,7 +172,7 @@ class BackupService {
}
final Set<String> existing = {};
try {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final String deviceId = Store.get(StoreKey.deviceId);
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetApi.checkExistingAssets(
CheckExistingAssetsDto(
@@ -215,8 +203,8 @@ class BackupService {
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb,
) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
final String deviceId = Store.get(StoreKey.deviceId);
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
File? file;
bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
@@ -247,21 +235,22 @@ class BackupService {
),
);
var box = Hive.box(userInfoBox);
var req = MultipartRequest(
'POST',
Uri.parse('$savedEndpoint/asset/upload'),
onProgress: ((bytes, totalBytes) =>
uploadProgressCb(bytes, totalBytes)),
);
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
req.headers["Authorization"] =
"Bearer ${Store.get(StoreKey.accessToken)}";
req.headers["Transfer-Encoding"] = "chunked";
req.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId;
req.fields['assetType'] = _getAssetType(entity.type);
req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String();
req.fields['fileModifiedAt'] = entity.modifiedDateTime.toIso8601String();
req.fields['fileModifiedAt'] =
entity.modifiedDateTime.toIso8601String();
req.fields['isFavorite'] = entity.isFavorite.toString();
req.fields['fileExtension'] = fileExtension;
req.fields['duration'] = entity.videoDuration.toString();
@@ -332,7 +321,7 @@ class BackupService {
}
}
if (duplicatedAssetIds.isNotEmpty) {
_saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds);
await _saveDuplicatedAssetIds(duplicatedAssetIds);
}
return !anyErrors;
}
@@ -375,31 +364,6 @@ class BackupService {
return "OTHER";
}
}
Future<DeviceInfoResponseDto> setAutoBackup(
bool status,
String deviceId,
DeviceTypeEnum deviceType,
) async {
try {
var updatedDeviceInfo = await _apiService.deviceInfoApi.upsertDeviceInfo(
UpsertDeviceInfoDto(
deviceId: deviceId,
deviceType: deviceType,
isAutoBackup: status,
),
);
if (updatedDeviceInfo == null) {
throw Exception("Error updating device info");
}
return updatedDeviceInfo;
} catch (e) {
debugPrint("Error setAutoBackup: ${e.toString()}");
throw Error();
}
}
}
class MultipartRequest extends http.MultipartRequest {

View File

@@ -137,6 +137,7 @@ class AlbumInfoCard extends HookConsumerWidget {
}
},
child: Card(
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // if you need this
@@ -150,20 +151,17 @@ class AlbumInfoCard extends HookConsumerWidget {
elevation: 0,
borderOnForeground: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Stack(
children: [
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
image: DecorationImage(
colorFilter: buildImageFilter(),
Expanded(
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
ColorFiltered(
colorFilter: buildImageFilter(),
child: Image(
width: double.infinity,
height: double.infinity,
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage(
@@ -172,58 +170,56 @@ class AlbumInfoCard extends HookConsumerWidget {
fit: BoxFit.cover,
),
),
child: null,
),
Positioned(
bottom: 10,
left: 25,
child: buildSelectedTextBox(),
)
],
Positioned(
bottom: 10,
right: 25,
child: buildSelectedTextBox(),
)
],
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
padding: const EdgeInsets.only(
left: 25,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 140,
child: Padding(
padding: const EdgeInsets.only(left: 25.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
albumInfo.name,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
albumInfo.name,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: 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,
),
)
],
),
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
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,
),
)
],
),
),
IconButton(

View File

@@ -0,0 +1,176 @@
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:flutter_hooks/flutter_hooks.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';
class AlbumInfoListTile extends HookConsumerWidget {
final Uint8List? imageData;
final AvailableAlbum albumInfo;
const AlbumInfoListTile({Key? key, this.imageData, required this.albumInfo})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected =
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
ColorFilter selectedFilter = ColorFilter.mode(
Theme.of(context).primaryColor.withAlpha(100),
BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
var assetCount = useState(0);
useEffect(
() {
albumInfo.assetCount.then((value) => assetCount.value = value);
return null;
},
[],
);
buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
return excludedFilter;
} else {
return unselectedFilter;
}
}
buildTileColor() {
if (isSelected) {
return isDarkTheme
? Theme.of(context).primaryColor.withAlpha(100)
: Theme.of(context).primaryColor.withAlpha(25);
} else if (isExcluded) {
return isDarkTheme
? Colors.red[300]?.withAlpha(150)
: Colors.red[100]?.withAlpha(150);
} else {
return Colors.transparent;
}
}
return GestureDetector(
onDoubleTap: () {
HapticFeedback.selectionClick();
if (isExcluded) {
// Remove from exclude album list
ref
.watch(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else {
// Add to exclude album list
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 &&
ref
.watch(backupProvider)
.selectedBackupAlbums
.contains(albumInfo)) {
ImmichToast.show(
context: context,
msg: "backup_err_only_album".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref
.watch(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
}
},
child: ListTile(
tileColor: buildTileColor(),
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
onTap: () {
HapticFeedback.selectionClick();
if (isSelected) {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
ImmichToast.show(
context: context,
msg: "backup_err_only_album".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.watch(backupProvider.notifier).removeAlbumForBackup(albumInfo);
} else {
ref.watch(backupProvider.notifier).addAlbumForBackup(albumInfo);
}
},
leading: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 80,
width: 80,
child: ColorFiltered(
colorFilter: buildImageFilter(),
child: Image(
width: double.infinity,
height: double.infinity,
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage(
'assets/immich-logo-no-outline.png',
) as ImageProvider,
fit: BoxFit.cover,
),
),
),
),
title: Text(
albumInfo.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(assetCount.value.toString()),
trailing: IconButton(
onPressed: () {
AutoRouter.of(context).push(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},
icon: Icon(
Icons.image_outlined,
color: Theme.of(context).primaryColor,
size: 24,
),
splashRadius: 25,
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ 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/modules/backup/ui/album_info_list_tile.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
@@ -18,7 +19,12 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final albums = ref.watch(backupProvider).availableAlbums;
final allAlbums = ref.watch(backupProvider).availableAlbums;
// Albums which are displayed to the user
// by filtering out based on search
final filteredAlbums = useState(allAlbums);
final albums = filteredAlbums.value;
useEffect(
() {
@@ -30,27 +36,53 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
buildAlbumSelectionList() {
if (albums.isEmpty) {
return const Center(
child: ImmichLoadingIndicator(),
return const SliverToBoxAdapter(
child: Center(
child: ImmichLoadingIndicator(),
),
);
}
return SizedBox(
height: 265,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: albums.length,
physics: const BouncingScrollPhysics(),
itemBuilder: ((context, index) {
var thumbnailData = albums[index].thumbnailData;
return Padding(
padding: index == 0
? const EdgeInsets.only(left: 16.00)
: const EdgeInsets.all(0),
child: AlbumInfoCard(
return SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
((context, index) {
var thumbnailData = albums[index].thumbnailData;
return AlbumInfoListTile(
imageData: thumbnailData,
albumInfo: albums[index],
),
);
}),
childCount: albums.length,
),
),
);
}
buildAlbumSelectionGrid() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: ImmichLoadingIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: albums.length,
itemBuilder: ((context, index) {
var thumbnailData = albums[index].thumbnailData;
return AlbumInfoCard(
imageData: thumbnailData,
albumInfo: albums[index],
);
}),
),
@@ -139,19 +171,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
child: TextFormField(
onChanged: (searchValue) {
var avaialbleAlbums = ref
.watch(backupProvider)
.availableAlbums
.where(
(album) => album.name
.toLowerCase()
.contains(searchValue.toLowerCase()),
)
.toList();
ref
.read(backupProvider.notifier)
.setAvailableAlbums(avaialbleAlbums);
if (searchValue.isEmpty) {
filteredAlbums.value = allAlbums;
} else {
filteredAlbums.value = allAlbums
.where(
(album) => album.name
.toLowerCase()
.contains(searchValue.toLowerCase()),
)
.toList();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
@@ -190,143 +220,162 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
).tr(),
elevation: 0,
),
body: ListView(
body: CustomScrollView(
physics: const ClampingScrollPhysics(),
children: [
Padding(
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,
),
).tr(),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...buildSelectedAlbumNameChip(),
...buildExcludedAlbumNameChip()
Padding(
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,
),
).tr(),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
...buildSelectedAlbumNameChip(),
...buildExcludedAlbumNameChip()
],
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Card(
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 0, 0, 0)
: const Color.fromARGB(255, 235, 235, 235),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
children: [
ListTile(
visualDensity: VisualDensity.compact,
title: const Text(
"backup_album_selection_page_total_assets",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
).tr(),
trailing: Text(
ref
.watch(backupProvider)
.allUniqueAssets
.length
.toString(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
),
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
args: [
ref
.watch(backupProvider)
.availableAlbums
.length
.toString()
],
),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: Theme.of(context).primaryColor,
),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).tr(),
],
),
),
);
},
);
},
),
),
buildSearchBar(),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Card(
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 0, 0, 0)
: const Color.fromARGB(255, 235, 235, 235),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
children: [
ListTile(
visualDensity: VisualDensity.compact,
title: const Text(
"backup_album_selection_page_total_assets",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
).tr(),
trailing: Text(
ref
.watch(backupProvider)
.allUniqueAssets
.length
.toString(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
),
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
args: [
ref.watch(backupProvider).availableAlbums.length.toString()
],
),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: Theme.of(context).primaryColor,
),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).tr(),
],
),
),
);
},
);
},
),
),
buildSearchBar(),
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: buildAlbumSelectionList(),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return buildAlbumSelectionGrid();
} else {
return buildAlbumSelectionList();
}
},
),
],
),

View File

@@ -10,9 +10,7 @@ import 'package:immich_mobile/modules/backup/providers/error_backup_list.provide
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.dart';
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
@@ -26,11 +24,10 @@ class BackupControllerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
AuthenticationState authenticationState = ref.watch(authenticationProvider);
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
final appRefreshDisabled = Platform.isIOS &&
settings?.appRefreshEnabled != true;
final appRefreshDisabled =
Platform.isIOS && settings?.appRefreshEnabled != true;
bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length -
@@ -102,11 +99,11 @@ class BackupControllerPage extends HookConsumerWidget {
}
ListTile buildAutoBackupController() {
var backUpOption = authenticationState.deviceInfo.isAutoBackup
final isAutoBackup = backupState.autoBackup;
final backUpOption = isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
var isAutoBackup = authenticationState.deviceInfo.isAutoBackup;
var backupBtnText = authenticationState.deviceInfo.isAutoBackup
final backupBtnText = isAutoBackup
? "backup_controller_page_turn_off".tr()
: "backup_controller_page_turn_on".tr();
return ListTile(
@@ -134,17 +131,9 @@ class BackupControllerPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ElevatedButton(
onPressed: () {
if (isAutoBackup) {
ref
.read(authenticationProvider.notifier)
.setAutoBackup(false);
} else {
ref
.read(authenticationProvider.notifier)
.setAutoBackup(true);
}
},
onPressed: () => ref
.read(backupProvider.notifier)
.setAutoBackup(!isAutoBackup),
child: Text(
backupBtnText,
style: const TextStyle(
@@ -292,15 +281,13 @@ class BackupControllerPage extends HookConsumerWidget {
dense: true,
activeColor: activeColor,
value: isWifiRequired,
onChanged: hasExclusiveAccess
? (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
)
: null,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
),
if (isBackgroundEnabled)
SwitchListTile.adaptive(
@@ -314,21 +301,18 @@ class BackupControllerPage extends HookConsumerWidget {
dense: true,
activeColor: activeColor,
value: isChargingRequired,
onChanged: hasExclusiveAccess
? (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
)
: null,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
),
if (isBackgroundEnabled && Platform.isAndroid)
ListTile(
isThreeLine: false,
dense: true,
enabled: hasExclusiveAccess,
title: const Text(
'backup_controller_page_background_delay',
style: TextStyle(
@@ -339,9 +323,7 @@ class BackupControllerPage extends HookConsumerWidget {
),
subtitle: Slider(
value: triggerDelay.value,
onChanged: hasExclusiveAccess
? (double v) => triggerDelay.value = v
: null,
onChanged: (double v) => triggerDelay.value = v,
onChangeEnd: (double v) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
@@ -379,23 +361,21 @@ class BackupControllerPage extends HookConsumerWidget {
if (isBackgroundEnabled && Platform.isIOS)
FutureBuilder(
future: ref
.read(backgroundServiceProvider)
.getIOSBackgroundAppRefreshEnabled(),
.read(backgroundServiceProvider)
.getIOSBackgroundAppRefreshEnabled(),
builder: (context, snapshot) {
final enabled = snapshot.data as bool?;
// If it's not enabled, show them some kind of alert that says
// background refresh is not enabled
if (enabled != null && !enabled) {
}
if (enabled != null && !enabled) {}
// If it's enabled, no need to bother them
return Container();
},
),
if (isBackgroundEnabled && settings != null)
if (Platform.isIOS && isBackgroundEnabled && settings != null)
IosDebugInfoTile(
settings: settings,
),
),
],
);
}
@@ -403,7 +383,9 @@ class BackupControllerPage extends HookConsumerWidget {
Widget buildBackgroundAppRefreshWarning() {
return ListTile(
isThreeLine: true,
leading: const Icon(Icons.task_outlined,),
leading: const Icon(
Icons.task_outlined,
),
title: const Text(
'backup_controller_page_background_app_refresh_disabled_title',
style: TextStyle(
@@ -420,7 +402,7 @@ class BackupControllerPage extends HookConsumerWidget {
'backup_controller_page_background_app_refresh_disabled_content',
).tr(),
),
ElevatedButton(
ElevatedButton(
onPressed: () => openAppSettings(),
child: const Text(
'backup_controller_page_background_app_refresh_enable_button_text',
@@ -533,12 +515,9 @@ class BackupControllerPage extends HookConsumerWidget {
),
),
trailing: ElevatedButton(
onPressed: hasExclusiveAccess
? () {
AutoRouter.of(context)
.push(const BackupAlbumSelectionRoute());
}
: null,
onPressed: () {
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
},
child: const Text(
"backup_controller_page_select",
style: TextStyle(
@@ -598,28 +577,12 @@ class BackupControllerPage extends HookConsumerWidget {
}
buildBackgroundBackupInfo() {
return hasExclusiveAccess
? const SizedBox.shrink()
: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), // if you need this
side: BorderSide(
color: isDarkMode
? const Color.fromARGB(255, 56, 56, 56)
: 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),
),
),
);
return const ListTile(
leading: Icon(Icons.info_outline_rounded),
title: Text(
"Background backup is currently running, cannot start manual backup",
),
);
}
return Scaffold(
@@ -652,7 +615,6 @@ class BackupControllerPage extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
).tr(),
),
buildBackgroundBackupInfo(),
buildFolderSelectionTile(),
BackupInfoCard(
title: "backup_controller_page_total".tr(),
@@ -681,22 +643,20 @@ class BackupControllerPage extends HookConsumerWidget {
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Platform.isIOS
? (
appRefreshDisabled
? buildBackgroundAppRefreshWarning()
: buildBackgroundBackupController()
) : buildBackgroundBackupController(),
? (appRefreshDisabled
? buildBackgroundAppRefreshWarning()
: buildBackgroundBackupController())
: buildBackgroundBackupController(),
),
const Divider(),
buildStorageInformation(),
const Divider(),
const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton()
],
),
),
);
}
}

View File

@@ -2,17 +2,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
FavoriteSelectionNotifier(this.ref) : super({}) {
state = ref.watch(assetProvider).allAssets
class FavoriteSelectionNotifier extends StateNotifier<Set<int>> {
FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) {
state = assetsState.allAssets
.where((asset) => asset.isFavorite)
.map((asset) => asset.id)
.toSet();
}
final Ref ref;
final AssetsState assetsState;
final AssetNotifier assetNotifier;
void _setFavoriteForAssetId(String id, bool favorite) {
void _setFavoriteForAssetId(int id, bool favorite) {
if (!favorite) {
state = state.difference({id});
} else {
@@ -20,7 +21,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
}
}
bool _isFavorite(String id) {
bool _isFavorite(int id) {
return state.contains(id);
}
@@ -29,7 +30,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
_setFavoriteForAssetId(asset.id, !_isFavorite(asset.id));
await ref.watch(assetProvider.notifier).toggleFavorite(
await assetNotifier.toggleFavorite(
asset,
state.contains(asset.id),
);
@@ -37,20 +38,23 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
Future<void> addToFavorites(Iterable<Asset> assets) {
state = state.union(assets.map((a) => a.id).toSet());
final futures = assets.map((a) =>
ref.watch(assetProvider.notifier).toggleFavorite(
a,
true,
),
);
final futures = assets.map(
(a) => assetNotifier.toggleFavorite(
a,
true,
),
);
return Future.wait(futures);
}
}
final favoriteProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) {
return FavoriteSelectionNotifier(ref);
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
return FavoriteSelectionNotifier(
ref.watch(assetProvider),
ref.watch(assetProvider.notifier),
);
});
final favoriteAssetProvider = StateProvider((ref) {

View File

@@ -3,9 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/favorite/ui/favorite_image.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/modules/home/ui/asset_grid/immich_asset_grid.dart';
class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({Key? key}) : super(key: key);
@@ -22,46 +20,14 @@ class FavoritesPage extends HookConsumerWidget {
automaticallyImplyLeading: false,
title: const Text(
'favorites_page_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildImageGrid() {
final appSettingService = ref.watch(appSettingsServiceProvider);
if (ref.watch(favoriteAssetProvider).isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate(
(
BuildContext context,
int index,
) {
return FavoriteImage(
ref.watch(favoriteAssetProvider)[index],
ref.watch(favoriteAssetProvider),
);
},
childCount: ref.watch(favoriteAssetProvider).length,
),
),
);
}
return const SliverToBoxAdapter();
}
return Scaffold(
appBar: buildAppBar(),
body: CustomScrollView(
slivers: [buildImageGrid()],
body: ImmichAssetGrid(
assets: ref.watch(favoriteAssetProvider),
),
);
}

View File

@@ -1,300 +1,106 @@
import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'group_divider_title.dart';
import 'disable_multi_select_button.dart';
import 'draggable_scrollbar_custom.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
typedef ImmichAssetGridSelectionListener = void Function(
bool,
Set<Asset>,
);
class ImmichAssetGridState extends State<ImmichAssetGrid> {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
bool _scrolling = false;
final Set<String> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() {
return _selectedAssets
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
.whereNotNull()
.toSet();
}
void _callSelectionListener(bool selectionActive) {
widget.listener?.call(selectionActive, _getSelectedAssets());
}
void _selectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.add(e.id);
}
_callSelectionListener(true);
});
}
void _deselectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.remove(e.id);
}
_callSelectionListener(_selectedAssets.isNotEmpty);
});
}
void _deselectAll() {
setState(() {
_selectedAssets.clear();
});
_callSelectionListener(false);
}
bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
}
Widget _buildThumbnailOrPlaceholder(
Asset asset,
bool placeholder,
) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage(
asset: asset,
assetList: widget.allAssets,
multiselectEnabled: widget.selectionActive,
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
onSelect: () => _selectAssets([asset]),
onDeselect: () => _deselectAssets([asset]),
useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator,
);
}
Widget _buildAssetRow(
BuildContext context,
RenderAssetGridRow row,
bool scrolling,
) {
return LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.mapIndexed((int index, Asset asset) {
bool last = asset.id == row.assets.last.id;
return Container(
key: Key("asset-${asset.id}"),
width: size * row.widthDistribution[index],
height: size,
margin: EdgeInsets.only(
top: widget.margin,
right: last ? 0.0 : widget.margin,
),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
},
);
}
Widget _buildTitle(
BuildContext context,
String title,
List<Asset> assets,
) {
return GroupDividerTitle(
text: title,
multiselectEnabled: widget.selectionActive,
onSelect: () => _selectAssets(assets),
onDeselect: () => _deselectAssets(assets),
selected: _allAssetsSelected(assets),
);
}
Widget _buildMonthTitle(BuildContext context, String title) {
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
title,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.displayLarge?.color,
),
),
);
}
Widget _itemBuilder(BuildContext c, int position) {
final item = widget.renderList.elements[position];
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, _scrolling);
}
return const Text("Invalid widget type!");
}
Text _labelBuilder(int pos) {
final date = widget.renderList.elements[pos].date;
return Text(
DateFormat.yMMMM().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildMultiSelectIndicator() {
return DisableMultiSelectButton(
onPressed: () => _deselectAll(),
selectedItemCount: _selectedAssets.length,
);
}
Widget _buildAssetGrid() {
final useDragScrolling = widget.allAssets.length >= 20;
void dragScrolling(bool active) {
setState(() {
_scrolling = active;
});
}
final listWidget = ScrollablePositionedList.builder(
padding: const EdgeInsets.only(
bottom: 220,
),
itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController,
itemCount: widget.renderList.elements.length,
addRepaintBoundaries: true,
);
if (!useDragScrolling) {
return listWidget;
}
return DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: listWidget,
);
}
@override
void didUpdateWidget(ImmichAssetGrid oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.selectionActive) {
setState(() {
_selectedAssets.clear();
});
}
}
Future<bool> onWillPop() async {
if (widget.selectionActive && _selectedAssets.isNotEmpty) {
_deselectAll();
return false;
}
return true;
}
@override
void initState() {
super.initState();
scrollToTopNotifierProvider.addListener(_scrollToTop);
}
@override
void dispose() {
scrollToTopNotifierProvider.removeListener(_scrollToTop);
super.dispose();
}
void _scrollToTop() {
// for some reason, this is necessary as well in order
// to correctly reposition the drag thumb scroll bar
_itemScrollController.jumpTo(
index: 0,
);
_itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 200),
);
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: onWillPop,
child: Stack(
children: [
_buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(),
],
),
);
}
}
class ImmichAssetGrid extends StatefulWidget {
final RenderList renderList;
final int assetsPerRow;
class ImmichAssetGrid extends HookConsumerWidget {
final int? assetsPerRow;
final double margin;
final bool showStorageIndicator;
final bool? showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
final List<Asset> allAssets;
final List<Asset> assets;
final RenderList? renderList;
final Future<void> Function()? onRefresh;
const ImmichAssetGrid({
super.key,
required this.renderList,
required this.allAssets,
required this.assetsPerRow,
required this.showStorageIndicator,
required this.assets,
this.onRefresh,
this.renderList,
this.assetsPerRow,
this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.selectionActive = false,
});
@override
State<StatefulWidget> createState() {
return ImmichAssetGridState();
Widget build(BuildContext context, WidgetRef ref) {
var settings = ref.watch(appSettingsServiceProvider);
final renderListFuture = ref.watch(renderListProvider(assets));
// Needs to suppress hero animations when navigating to this widget
final enableHeroAnimations = useState(false);
// Wait for transition to complete, then re-enable
ModalRoute.of(context)?.animation?.addListener(() {
// If we've already enabled, we are done
if (enableHeroAnimations.value) {
return;
}
final animation = ModalRoute.of(context)?.animation;
if (animation != null) {
// When the animation is complete, re-enable hero animations
enableHeroAnimations.value = animation.isCompleted;
}
});
Future<bool> onWillPop() async {
enableHeroAnimations.value = false;
return true;
}
if (renderList != null) {
return WillPopScope(
onWillPop: onWillPop,
child: HeroMode(
enabled: enableHeroAnimations.value,
child: ImmichAssetGridView(
allAssets: assets,
onRefresh: onRefresh,
assetsPerRow: assetsPerRow ??
settings.getSetting(AppSettingsEnum.tilesPerRow),
listener: listener,
showStorageIndicator: showStorageIndicator ??
settings.getSetting(AppSettingsEnum.storageIndicator),
renderList: renderList!,
margin: margin,
selectionActive: selectionActive,
),
),
);
}
return renderListFuture.when(
data: (renderList) => WillPopScope(
onWillPop: onWillPop,
child: HeroMode(
enabled: enableHeroAnimations.value,
child: ImmichAssetGridView(
allAssets: assets,
onRefresh: onRefresh,
assetsPerRow: assetsPerRow ??
settings.getSetting(AppSettingsEnum.tilesPerRow),
listener: listener,
showStorageIndicator: showStorageIndicator ??
settings.getSetting(AppSettingsEnum.storageIndicator),
renderList: renderList,
margin: margin,
selectionActive: selectionActive,
),
),
),
error: (err, stack) => Center(child: Text("$err")),
loading: () => const Center(
child: ImmichLoadingIndicator(),
),
);
}
}

View File

@@ -0,0 +1,304 @@
import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'group_divider_title.dart';
import 'disable_multi_select_button.dart';
import 'draggable_scrollbar_custom.dart';
typedef ImmichAssetGridSelectionListener = void Function(
bool,
Set<Asset>,
);
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
bool _scrolling = false;
final Set<int> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() {
return _selectedAssets
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
.whereNotNull()
.toSet();
}
void _callSelectionListener(bool selectionActive) {
widget.listener?.call(selectionActive, _getSelectedAssets());
}
void _selectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.add(e.id);
}
_callSelectionListener(true);
});
}
void _deselectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.remove(e.id);
}
_callSelectionListener(_selectedAssets.isNotEmpty);
});
}
void _deselectAll() {
setState(() {
_selectedAssets.clear();
});
_callSelectionListener(false);
}
bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
}
Widget _buildThumbnailOrPlaceholder(
Asset asset,
bool placeholder,
) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage(
asset: asset,
assetList: widget.allAssets,
multiselectEnabled: widget.selectionActive,
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
onSelect: () => _selectAssets([asset]),
onDeselect: () => _deselectAssets([asset]),
useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator,
);
}
Widget _buildAssetRow(
BuildContext context,
RenderAssetGridRow row,
bool scrolling,
) {
return LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.mapIndexed((int index, Asset asset) {
bool last = asset.id == row.assets.last.id;
return Container(
key: Key("asset-${asset.id}"),
width: size * row.widthDistribution[index],
height: size,
margin: EdgeInsets.only(
top: widget.margin,
right: last ? 0.0 : widget.margin,
),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
},
);
}
Widget _buildTitle(
BuildContext context,
String title,
List<Asset> assets,
) {
return GroupDividerTitle(
text: title,
multiselectEnabled: widget.selectionActive,
onSelect: () => _selectAssets(assets),
onDeselect: () => _deselectAssets(assets),
selected: _allAssetsSelected(assets),
);
}
Widget _buildMonthTitle(BuildContext context, String title) {
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
title,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.displayLarge?.color,
),
),
);
}
Widget _itemBuilder(BuildContext c, int position) {
final item = widget.renderList.elements[position];
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, _scrolling);
}
return const Text("Invalid widget type!");
}
Text _labelBuilder(int pos) {
final date = widget.renderList.elements[pos].date;
return Text(
DateFormat.yMMMM().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildMultiSelectIndicator() {
return DisableMultiSelectButton(
onPressed: () => _deselectAll(),
selectedItemCount: _selectedAssets.length,
);
}
Widget _buildAssetGrid() {
final useDragScrolling = widget.allAssets.length >= 20;
void dragScrolling(bool active) {
setState(() {
_scrolling = active;
});
}
final listWidget = ScrollablePositionedList.builder(
padding: const EdgeInsets.only(
bottom: 220,
),
itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController,
itemCount: widget.renderList.elements.length,
addRepaintBoundaries: true,
);
final child = useDragScrolling
? DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: listWidget,
)
: listWidget;
return widget.onRefresh == null
? child
: RefreshIndicator(onRefresh: widget.onRefresh!, child: child);
}
@override
void didUpdateWidget(ImmichAssetGridView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.selectionActive) {
setState(() {
_selectedAssets.clear();
});
}
}
Future<bool> onWillPop() async {
if (widget.selectionActive && _selectedAssets.isNotEmpty) {
_deselectAll();
return false;
}
return true;
}
@override
void initState() {
super.initState();
scrollToTopNotifierProvider.addListener(_scrollToTop);
}
@override
void dispose() {
scrollToTopNotifierProvider.removeListener(_scrollToTop);
super.dispose();
}
void _scrollToTop() {
// for some reason, this is necessary as well in order
// to correctly reposition the drag thumb scroll bar
_itemScrollController.jumpTo(
index: 0,
);
_itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 200),
);
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: onWillPop,
child: Stack(
children: [
_buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(),
],
),
);
}
}
class ImmichAssetGridView extends StatefulWidget {
final RenderList renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
final List<Asset> allAssets;
final Future<void> Function()? onRefresh;
const ImmichAssetGridView({
super.key,
required this.renderList,
required this.allAssets,
required this.assetsPerRow,
required this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.selectionActive = false,
this.onRefresh,
});
@override
State<StatefulWidget> createState() {
return ImmichAssetGridViewState();
}
}

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
@@ -32,8 +31,6 @@ class ThumbnailImage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget buildSelectionIcon(Asset asset) {
if (isSelected) {
return Icon(
@@ -70,6 +67,31 @@ class ThumbnailImage extends HookConsumerWidget {
HapticFeedback.heavyImpact();
},
child: Hero(
createRectTween: (begin, end) {
double? top;
// Uses the [BoxFit.contain] algorithm
if (asset.width != null && asset.height != null) {
final assetAR = asset.width! / asset.height!;
final w = MediaQuery.of(context).size.width;
final deviceAR = MediaQuery.of(context).size.aspectRatio;
if (deviceAR < assetAR) {
top = asset.height! * w / asset.width!;
} else {
top = 0;
}
// get the height offset
}
return MaterialRectCenterArcTween(
begin: Rect.fromLTRB(
0,
top ?? 0.0,
MediaQuery.of(context).size.width,
MediaQuery.of(context).size.height,
),
end: end,
);
},
tag: asset.id,
child: Stack(
children: [
@@ -103,7 +125,7 @@ class ThumbnailImage extends HookConsumerWidget {
bottom: 5,
child: Icon(
asset.isRemote
? (deviceId == asset.deviceId
? (asset.isLocal
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,

View File

@@ -1,10 +1,7 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -13,7 +10,6 @@ import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
@override
@@ -29,8 +25,8 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
bool isEnableAutoBackup = backupState.backgroundBackup ||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final bool isEnableAutoBackup =
backupState.backgroundBackup || backupState.autoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
AuthenticationState authState = ref.watch(authenticationProvider);
@@ -47,27 +43,13 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
},
);
} else {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var dummy = Random().nextInt(1024);
return InkWell(
onTap: () {
Scaffold.of(context).openDrawer();
},
child: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: const UserCircleAvatar(
radius: 18,
child: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: FadeInImage.memoryNetwork(
fit: BoxFit.cover,
placeholder: kTransparentImage,
width: 33,
height: 33,
image:
'$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
fadeInDuration: const Duration(milliseconds: 200),
),
),
size: 33,
),
);
}

View File

@@ -1,16 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
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/modules/home/ui/user_circle_avatar.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/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class ProfileDrawerHeader extends HookConsumerWidget {
const ProfileDrawerHeader({
@@ -19,29 +15,15 @@ class ProfileDrawerHeader extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
AuthenticationState authState = ref.watch(authenticationProvider);
final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status;
var dummy = Random().nextInt(1024);
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
buildUserProfileImage() {
var userImage = CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
var userImage = const UserCircleAvatar(
radius: 35,
child: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: FadeInImage.memoryNetwork(
fit: BoxFit.cover,
placeholder: kTransparentImage,
width: 66,
height: 66,
image:
'$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
fadeInDuration: const Duration(milliseconds: 200),
),
),
size: 66,
);
if (authState.profileImagePath.isEmpty) {

View File

@@ -106,7 +106,9 @@ class ServerInfoBox extends HookConsumerWidget {
),
),
Text(
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}"
: "?",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],

View File

@@ -0,0 +1,44 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/store.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class UserCircleAvatar extends ConsumerWidget {
final double radius;
final double size;
const UserCircleAvatar({super.key, required this.radius, required this.size});
@override
Widget build(BuildContext context, WidgetRef ref) {
AuthenticationState authState = ref.watch(authenticationProvider);
var profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${authState.userId}?d=${Random().nextInt(1024)}';
return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
radius: radius,
child: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: FadeInImage(
fit: BoxFit.cover,
placeholder: MemoryImage(kTransparentImage),
width: size,
height: size,
image: NetworkImage(
profileImageUrl,
headers: {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
},
),
fadeInDuration: const Duration(milliseconds: 200),
imageErrorBuilder: (context, error, stackTrace) =>
Image.memory(kTransparentImage),
),
),
);
}
}

View File

@@ -38,11 +38,12 @@ class HomePage extends HookConsumerWidget {
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider);
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider);
final albumService = ref.watch(albumServiceProvider);
final tipOneOpacity = useState(0.0);
final refreshCount = useState(0);
useEffect(
() {
@@ -182,6 +183,22 @@ class HomePage extends HookConsumerWidget {
}
}
Future<void> refreshAssets() async {
debugPrint("refreshCount.value ${refreshCount.value}");
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (fullRefresh) {
// refresh was forced: user requested another refresh within 2 seconds
refreshCount.value = 0;
} else {
refreshCount.value++;
// set counter back to 0 if user does not request refresh again
Timer(const Duration(seconds: 2), () {
refreshCount.value = 0;
});
}
}
buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () {
tipOneOpacity.value = 1;
@@ -234,13 +251,14 @@ class HomePage extends HookConsumerWidget {
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: ref.watch(assetProvider).renderList!,
allAssets: ref.watch(assetProvider).allAssets,
assets: ref.watch(assetProvider).allAssets,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: refreshAssets,
),
if (selectionEnabledHook.value)
SafeArea(

View File

@@ -11,7 +11,6 @@ class AuthenticationState {
final bool isAdmin;
final bool shouldChangePassword;
final String profileImagePath;
final DeviceInfoResponseDto deviceInfo;
AuthenticationState({
required this.deviceId,
required this.deviceType,
@@ -23,7 +22,6 @@ class AuthenticationState {
required this.isAdmin,
required this.shouldChangePassword,
required this.profileImagePath,
required this.deviceInfo,
});
AuthenticationState copyWith({
@@ -37,7 +35,6 @@ class AuthenticationState {
bool? isAdmin,
bool? shouldChangePassword,
String? profileImagePath,
DeviceInfoResponseDto? deviceInfo,
}) {
return AuthenticationState(
deviceId: deviceId ?? this.deviceId,
@@ -50,13 +47,12 @@ class AuthenticationState {
isAdmin: isAdmin ?? this.isAdmin,
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
profileImagePath: profileImagePath ?? this.profileImagePath,
deviceInfo: deviceInfo ?? this.deviceInfo,
);
}
@override
String toString() {
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath)';
}
@override
@@ -73,8 +69,7 @@ class AuthenticationState {
other.lastName == lastName &&
other.isAdmin == isAdmin &&
other.shouldChangePassword == shouldChangePassword &&
other.profileImagePath == profileImagePath &&
other.deviceInfo == deviceInfo;
other.profileImagePath == profileImagePath;
}
@override
@@ -88,7 +83,6 @@ class AuthenticationState {
lastName.hashCode ^
isAdmin.hashCode ^
shouldChangePassword.hashCode ^
profileImagePath.hashCode ^
deviceInfo.hashCode;
profileImagePath.hashCode;
}
}

View File

@@ -1,27 +1,21 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:openapi/api.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier(
this._deviceInfoService,
this._backupService,
this._apiService,
this._assetCacheService,
this._albumCacheService,
this._sharedAlbumCacheService,
) : super(
AuthenticationState(
deviceId: "",
@@ -34,23 +28,11 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
isAdmin: false,
shouldChangePassword: false,
isAuthenticated: false,
deviceInfo: DeviceInfoResponseDto(
id: 0,
userId: "",
deviceId: "",
deviceType: DeviceTypeEnum.ANDROID,
createdAt: "",
isAutoBackup: false,
),
),
);
final DeviceInfoService _deviceInfoService;
final BackupService _backupService;
final ApiService _apiService;
final AssetCacheService _assetCacheService;
final AlbumCacheService _albumCacheService;
final SharedAlbumCacheService _sharedAlbumCacheService;
Future<bool> login(
String email,
@@ -95,13 +77,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
try {
await Future.wait([
_apiService.authenticationApi.logout(),
Hive.box(userInfoBox).delete(accessTokenKey),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.userRemoteId),
_assetCacheService.invalidate(),
_albumCacheService.invalidate(),
_sharedAlbumCacheService.invalidate(),
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey)
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
]);
state = state.copyWith(isAuthenticated: false);
@@ -113,18 +91,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
}
}
setAutoBackup(bool backupState) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"];
DeviceTypeEnum deviceType = deviceInfo["deviceType"];
DeviceInfoResponseDto updatedDeviceInfo =
await _backupService.setAutoBackup(backupState, deviceId, deviceType);
state = state.copyWith(deviceInfo: updatedDeviceInfo);
}
updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
@@ -153,14 +119,22 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
required String serverUrl,
}) async {
_apiService.setAccessToken(accessToken);
var userResponseDto = await _apiService.userApi.getMyUserInfo();
UserResponseDto? userResponseDto;
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
} on ApiException catch (e) {
if (e.innerException is SocketException) {
state = state.copyWith(isAuthenticated: true);
}
}
if (userResponseDto != null) {
var userInfoHiveBox = await Hive.openBox(userInfoBox);
var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
userInfoHiveBox.put(accessTokenKey, accessToken);
Store.put(StoreKey.userRemoteId, userResponseDto.id);
Store.put(StoreKey.deviceId, deviceInfo["deviceId"]);
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
state = state.copyWith(
isAuthenticated: true,
@@ -174,40 +148,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
deviceId: deviceInfo["deviceId"],
deviceType: deviceInfo["deviceType"],
);
// Save login info to local storage
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
HiveSavedLoginInfo(
email: "",
password: "",
serverUrl: serverUrl,
accessToken: accessToken,
),
);
}
// Register device info
try {
DeviceInfoResponseDto? deviceInfo =
await _apiService.deviceInfoApi.upsertDeviceInfo(
UpsertDeviceInfoDto(
deviceId: state.deviceId,
deviceType: state.deviceType,
),
);
if (deviceInfo == null) {
debugPrint('Device Info Response is null');
return false;
}
state = state.copyWith(deviceInfo: deviceInfo);
} catch (e) {
debugPrint("ERROR Register Device Info: $e");
return false;
}
return true;
}
}
@@ -216,10 +157,6 @@ final authenticationProvider =
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(
ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(apiServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(albumCacheServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});

View File

@@ -1,20 +1,22 @@
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/hive.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/oauth.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/ui/immich_logo.dart';
import 'package:immich_mobile/shared/ui/immich_title_text.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:openapi/api.dart';
import 'package:permission_handler/permission_handler.dart';
class LoginForm extends HookConsumerWidget {
const LoginForm({Key? key}) : super(key: key);
@@ -28,58 +30,81 @@ class LoginForm extends HookConsumerWidget {
final serverEndpointController =
useTextEditingController.fromValue(TextEditingValue.empty);
final apiService = ref.watch(apiServiceProvider);
final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode();
final serverEndpointFocusNode = useFocusNode();
final isLoading = useState<bool>(false);
final isLoadingServer = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
final logoAnimationController = useAnimationController(
duration: const Duration(seconds: 60),
)..repeat();
getServeLoginConfig() async {
if (!serverEndpointFocusNode.hasFocus) {
var serverUrl = serverEndpointController.text.trim();
final ValueNotifier<String?> serverEndpoint = useState<String?>(null);
try {
if (serverUrl.isNotEmpty) {
isLoading.value = true;
final serverEndpoint =
await apiService.resolveAndSetEndpoint(serverUrl.toString());
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<bool> getServerLoginCredential() async {
final serverUrl = serverEndpointController.text.trim();
var loginConfig = await apiService.oAuthApi.generateConfig(
OAuthConfigDto(redirectUri: serverEndpoint),
);
// Guard empty URL
if (serverUrl.isEmpty) {
ImmichToast.show(
context: context,
msg: "login_form_server_empty".tr(),
toastType: ToastType.error,
);
if (loginConfig != null) {
isOauthEnable.value = loginConfig.enabled;
oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth';
} else {
isOauthEnable.value = false;
}
return false;
}
isLoading.value = false;
}
} catch (_) {
isLoading.value = false;
try {
isLoadingServer.value = true;
final endpoint = await apiService.resolveAndSetEndpoint(serverUrl);
final loginConfig = await apiService.oAuthApi.generateConfig(
OAuthConfigDto(redirectUri: serverUrl),
);
if (loginConfig != null) {
isOauthEnable.value = loginConfig.enabled;
oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth';
} else {
isOauthEnable.value = false;
}
serverEndpoint.value = endpoint;
} on ApiException catch (e) {
ImmichToast.show(
context: context,
msg: e.message ?? 'login_form_api_exception'.tr(),
toastType: ToastType.error,
);
isOauthEnable.value = false;
isLoadingServer.value = false;
return false;
} catch (e) {
ImmichToast.show(
context: context,
msg: 'login_form_server_error'.tr(),
toastType: ToastType.error,
);
isOauthEnable.value = false;
isLoadingServer.value = false;
return false;
}
isLoadingServer.value = false;
return true;
}
useEffect(
() {
serverEndpointFocusNode.addListener(getServeLoginConfig);
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
.get(savedLoginInfoKey);
if (loginInfo != null) {
usernameController.text = loginInfo.email;
passwordController.text = loginInfo.password;
serverEndpointController.text = loginInfo.serverUrl;
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null) {
serverEndpointController.text = serverUrl;
}
getServeLoginConfig();
return null;
},
[],
@@ -91,229 +116,33 @@ class LoginForm extends HookConsumerWidget {
serverEndpointController.text = 'http://10.1.15.216:2283/api';
}
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: SingleChildScrollView(
child: AutofillGroup(
child: Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: [
GestureDetector(
onDoubleTap: () => populateTestLoginInfo(),
child: RotationTransition(
turns: logoAnimationController,
child: const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 100,
filterQuality: FilterQuality.high,
),
),
),
Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 48,
color: Theme.of(context).primaryColor,
),
),
EmailInput(controller: usernameController),
PasswordInput(controller: passwordController),
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
),
if (isLoading.value)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
if (!isLoading.value)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
LoginButton(
emailController: usernameController,
passwordController: passwordController,
serverEndpointController: serverEndpointController,
),
if (isOauthEnable.value) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Divider(
color:
Brightness.dark == Theme.of(context).brightness
? Colors.white
: Colors.black,
),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onLoginSuccess: () {
isLoading.value = false;
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).replace(
const TabControllerRoute(),
);
},
),
],
],
)
],
),
),
),
),
);
}
}
login() async {
// Start loading
isLoading.value = true;
class ServerEndpointInput extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
const ServerEndpointInput({
Key? key,
required this.controller,
required this.focusNode,
}) : super(key: key);
// This will remove current cache asset state of previous user login.
ref.read(assetProvider.notifier).clearAllAsset();
String? _validateInput(String? url) {
if (url == null || url.isEmpty) return null;
final parsedUrl = Uri.tryParse(sanitizeUrl(url));
if (parsedUrl == null ||
!parsedUrl.isAbsolute ||
!parsedUrl.scheme.startsWith("http") ||
parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: 'login_form_endpoint_url'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_endpoint_hint'.tr(),
errorMaxLines: 4,
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
focusNode: focusNode,
autofillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
autocorrect: false,
);
}
}
class EmailInput extends StatelessWidget {
final TextEditingController controller;
const EmailInput({Key? key, required this.controller}) : super(key: key);
String? _validateInput(String? email) {
if (email == null || email == '') return null;
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
if (email.contains(' ') || !email.contains('@')) {
return 'login_form_err_invalid_email'.tr();
}
return null;
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: 'login_form_label_email'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_email_hint'.tr(),
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
autofillHints: const [AutofillHints.email],
keyboardType: TextInputType.emailAddress,
);
}
}
class PasswordInput extends StatelessWidget {
final TextEditingController controller;
const PasswordInput({Key? key, required this.controller}) : super(key: key);
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: true,
controller: controller,
decoration: InputDecoration(
labelText: 'login_form_label_password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
);
}
}
class LoginButton extends ConsumerWidget {
final TextEditingController emailController;
final TextEditingController passwordController;
final TextEditingController serverEndpointController;
const LoginButton({
Key? key,
required this.emailController,
required this.passwordController,
required this.serverEndpointController,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: () async {
// This will remove current cache asset state of previous user login.
ref.read(assetProvider.notifier).clearAllAsset();
var isAuthenticated =
try {
final isAuthenticated =
await ref.read(authenticationProvider.notifier).login(
emailController.text,
usernameController.text,
passwordController.text,
serverEndpointController.text,
serverEndpointController.text.trim(),
);
if (isAuthenticated) {
// Resume backup (if enable) then navigate
if (ref.read(authenticationProvider).shouldChangePassword &&
!ref.read(authenticationProvider).isAdmin) {
AutoRouter.of(context).push(const ChangePasswordRoute());
} else {
ref.read(backupProvider.notifier).resumeBackup();
final hasPermission = await ref
.read(galleryPermissionNotifier.notifier)
.hasPermission;
if (hasPermission) {
// Don't resume the backup until we have gallery permission
ref.read(backupProvider.notifier).resumeBackup();
}
AutoRouter.of(context).replace(const TabControllerRoute());
}
} else {
@@ -323,35 +152,14 @@ class LoginButton extends ConsumerWidget {
toastType: ToastType.error,
);
}
},
icon: const Icon(Icons.login_rounded),
label: const Text(
"login_form_button_text",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(),
);
}
}
} finally {
// Make sure we stop loading
isLoading.value = false;
}
}
class OAuthLoginButton extends ConsumerWidget {
final TextEditingController serverEndpointController;
final ValueNotifier<bool> isLoading;
final VoidCallback onLoginSuccess;
final String buttonLabel;
const OAuthLoginButton({
Key? key,
required this.serverEndpointController,
required this.isLoading,
required this.onLoginSuccess,
required this.buttonLabel,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var oAuthService = ref.watch(oAuthServiceProvider);
void performOAuthLogin() async {
oAuthLogin() async {
var oAuthService = ref.watch(oAuthServiceProvider);
ref.watch(assetProvider.notifier).clearAllAsset();
OAuthConfigResponseDto? oAuthServerConfig;
@@ -384,7 +192,13 @@ class OAuthLoginButton extends ConsumerWidget {
if (isSuccess) {
isLoading.value = false;
onLoginSuccess();
final permission = ref.watch(galleryPermissionNotifier);
if (permission.isGranted || permission.isLimited) {
ref.watch(backupProvider.notifier).resumeBackup();
}
AutoRouter.of(context).replace(
const TabControllerRoute(),
);
} else {
ImmichToast.show(
context: context,
@@ -406,12 +220,337 @@ class OAuthLoginButton extends ConsumerWidget {
}
}
buildSelectServer() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: getServerLoginCredential,
),
const SizedBox(height: 18),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: isLoadingServer.value ? null : getServerLoginCredential,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text(
'login_form_next_button',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(),
),
if (isLoadingServer.value)
const Padding(
padding: EdgeInsets.only(top: 18.0),
child: Center(
child: CircularProgressIndicator(),
),
),
],
);
}
buildLogin() {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
serverEndpointController.text,
style: Theme.of(context).textTheme.displaySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 18),
EmailInput(
controller: usernameController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(
controller: passwordController,
focusNode: passwordFocusNode,
onSubmit: login,
),
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const Padding(
padding: EdgeInsets.only(top: 18.0),
child: SizedBox(
width: 24,
height: 24,
child: FittedBox(
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Divider(
color:
Brightness.dark == Theme.of(context).brightness
? Colors.white
: Colors.black,
),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
),
],
],
),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('Back'),
),
],
),
);
}
final serverSelectionOrLogin =
serverEndpoint.value == null ? buildSelectServer() : buildLogin();
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 300),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: constraints.maxHeight / 5,
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.end,
children: [
GestureDetector(
onDoubleTap: () => populateTestLoginInfo(),
child: RotationTransition(
turns: logoAnimationController,
child: const ImmichLogo(
heroTag: 'logo',
),
),
),
const ImmichTitleText(),
],
),
const SizedBox(height: 18),
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
serverSelectionOrLogin,
],
),
),
),
);
},
);
}
}
class ServerEndpointInput extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final Function()? onSubmit;
const ServerEndpointInput({
Key? key,
required this.controller,
required this.focusNode,
this.onSubmit,
}) : super(key: key);
String? _validateInput(String? url) {
if (url == null || url.isEmpty) return null;
final parsedUrl = Uri.tryParse(sanitizeUrl(url));
if (parsedUrl == null ||
!parsedUrl.isAbsolute ||
!parsedUrl.scheme.startsWith("http") ||
parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: 'login_form_endpoint_url'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_endpoint_hint'.tr(),
errorMaxLines: 4,
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
focusNode: focusNode,
autofillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
autocorrect: false,
onFieldSubmitted: (_) => onSubmit?.call(),
textInputAction: TextInputAction.go,
);
}
}
class EmailInput extends StatelessWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final Function()? onSubmit;
const EmailInput({
Key? key,
required this.controller,
this.focusNode,
this.onSubmit,
}) : super(key: key);
String? _validateInput(String? email) {
if (email == null || email == '') return null;
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
if (email.contains(' ') || !email.contains('@')) {
return 'login_form_err_invalid_email'.tr();
}
return null;
}
@override
Widget build(BuildContext context) {
return TextFormField(
autofocus: true,
controller: controller,
decoration: InputDecoration(
labelText: 'login_form_label_email'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_email_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
autofillHints: const [AutofillHints.email],
keyboardType: TextInputType.emailAddress,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
textInputAction: TextInputAction.next,
);
}
}
class PasswordInput extends StatelessWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final Function()? onSubmit;
const PasswordInput({
Key? key,
required this.controller,
this.focusNode,
this.onSubmit,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: true,
controller: controller,
decoration: InputDecoration(
labelText: 'login_form_label_password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
textInputAction: TextInputAction.go,
);
}
}
class LoginButton extends ConsumerWidget {
final Function() onPressed;
const LoginButton({
Key? key,
required this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: onPressed,
icon: const Icon(Icons.login_rounded),
label: const Text(
"login_form_button_text",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(),
);
}
}
class OAuthLoginButton extends ConsumerWidget {
final TextEditingController serverEndpointController;
final ValueNotifier<bool> isLoading;
final String buttonLabel;
final Function() onPressed;
const OAuthLoginButton({
Key? key,
required this.serverEndpointController,
required this.isLoading,
required this.buttonLabel,
required this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor.withAlpha(230),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: performOAuthLogin,
onPressed: onPressed,
icon: const Icon(Icons.pin_rounded),
label: Text(
buttonLabel,

View File

@@ -0,0 +1,101 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
GalleryPermissionNotifier()
: super(PermissionStatus.denied) // Denied is the intitial state
{
// Sets the initial state
getGalleryPermissionStatus();
}
get hasPermission => state.isGranted || state.isLimited;
/// Requests the gallery permission
Future<PermissionStatus> requestGalleryPermission() async {
// Android 32 and below uses Permission.storage
if (Platform.isAndroid) {
final androidInfo = await DeviceInfoPlugin().androidInfo;
if (androidInfo.version.sdkInt <= 32) {
// Android 32 and below need storage
final permission = await Permission.storage.request();
state = permission;
return permission;
} else {
// Android 33 need photo & video
final photos = await Permission.photos.request();
if (!photos.isGranted) {
// Don't ask twice for the same permission
return photos;
}
final videos = await Permission.videos.request();
// Return the joint result of those two permissions
final PermissionStatus status;
if (photos.isGranted && videos.isGranted) {
status = PermissionStatus.granted;
} else if (photos.isDenied || videos.isDenied) {
status = PermissionStatus.denied;
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
status = PermissionStatus.permanentlyDenied;
} else {
status = PermissionStatus.denied;
}
state = status;
return status;
}
} else {
// iOS can use photos
final photos = await Permission.photos.request();
state = photos;
return photos;
}
}
/// Checks the current state of the gallery permissions without
/// requesting them again
Future<PermissionStatus> getGalleryPermissionStatus() async {
// Android 32 and below uses Permission.storage
if (Platform.isAndroid) {
final androidInfo = await DeviceInfoPlugin().androidInfo;
if (androidInfo.version.sdkInt <= 32) {
// Android 32 and below need storage
final permission = await Permission.storage.status;
state = permission;
return permission;
} else {
// Android 33 needs photo & video
final photos = await Permission.photos.status;
final videos = await Permission.videos.status;
// Return the joint result of those two permissions
final PermissionStatus status;
if (photos.isGranted && videos.isGranted) {
status = PermissionStatus.granted;
} else if (photos.isDenied || videos.isDenied) {
status = PermissionStatus.denied;
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
status = PermissionStatus.permanentlyDenied;
} else {
status = PermissionStatus.denied;
}
state = status;
return status;
}
} else {
// iOS can use photos
final photos = await Permission.photos.status;
state = photos;
return photos;
}
}
}
final galleryPermissionNotifier
= StateNotifierProvider<GalleryPermissionNotifier, PermissionStatus>
((ref) => GalleryPermissionNotifier());

View File

@@ -0,0 +1,201 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_logo.dart';
import 'package:immich_mobile/shared/ui/immich_title_text.dart';
import 'package:permission_handler/permission_handler.dart';
class PermissionOnboardingPage extends HookConsumerWidget {
const PermissionOnboardingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final PermissionStatus permission = ref.watch(galleryPermissionNotifier);
// Navigate to the main Tab Controller when permission is granted
void goToHome() {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup()
.catchError((error) {
debugPrint('PermissionOnboardingPage error: $error');
});
AutoRouter.of(context).replace(
const TabControllerRoute(),
);
}
// When the permission is denied, we show a request permission page
buildRequestPermission() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'permission_onboarding_request',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 18),
ElevatedButton(
onPressed: () => ref
.read(galleryPermissionNotifier.notifier)
.requestGalleryPermission()
.then((permission) async {
if (permission.isGranted) {
// If permission is limited, we will show the limited
// permission page
goToHome();
}
}),
child: const Text(
'permission_onboarding_grant_permission',
).tr(),
),
],
);
}
// When permission is granted from outside the app, this will show to
// let them continue on to the main timeline
buildPermissionGranted() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'permission_onboarding_permission_granted',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 18),
ElevatedButton(
onPressed: () => goToHome(),
child: const Text('permission_onboarding_get_started').tr(),
),
],
);
}
// iOS 14+ has limited permission options, which let someone just share
// a few photos with the app. If someone only has limited permissions, we
// inform that Immich works best when given full permission
buildPermissionLimited() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.warning_outlined,
color: Colors.yellow,
size: 48,
),
const SizedBox(height: 8),
Text(
'permission_onboarding_permission_limited',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 18),
ElevatedButton(
onPressed: () => openAppSettings(),
child: const Text(
'permission_onboarding_go_to_settings',
).tr(),
),
const SizedBox(height: 8.0),
TextButton(
onPressed: () => goToHome(),
child: const Text(
'permission_onboarding_continue_anyway',
).tr(),
),
],
);
}
buildPermissionDenied() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.warning_outlined,
color: Colors.red,
size: 48,
),
const SizedBox(height: 8),
Text(
'permission_onboarding_permission_denied',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 18),
ElevatedButton(
onPressed: () => openAppSettings(),
child: const Text(
'permission_onboarding_go_to_settings',
).tr(),
),
],
);
}
final Widget child;
switch (permission) {
case PermissionStatus.limited:
child = buildPermissionLimited();
break;
case PermissionStatus.denied:
child = buildRequestPermission();
break;
case PermissionStatus.granted:
child = buildPermissionGranted();
break;
case PermissionStatus.restricted:
case PermissionStatus.permanentlyDenied:
child = buildPermissionDenied();
break;
}
return Scaffold(
body: SafeArea(
child: Center(
child: SizedBox(
width: 380,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ImmichLogo(
heroTag: 'logo',
),
const ImmichTitleText(),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Padding(
padding: const EdgeInsets.all(18.0),
child: child,
),
),
TextButton(
child: const Text('permission_onboarding_log_out').tr(),
onPressed: () {
ref.read(authenticationProvider.notifier).logout();
AutoRouter.of(context).replace(
const LoginRoute(),
);
},
),
],
),
),
),
),
);
}
}

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