Compare commits

...

58 Commits

Author SHA1 Message Date
Alex The Bot
c174f0e871 Version v1.90.0 2023-12-07 18:32:45 +00:00
shenlong
3581069c2b fix(mobile): do not show loading overlay on grid refresh (#5531)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-07 12:21:07 -06:00
shenlong
c5504aae6e refactor(mobile): album sort (#5510)
* refactor: migrate album sort option to provider

* refactor: use sort order in add to album sheet list

* test(mobile): album_sort_options_provider unit tests

* refactor: sort shared albums with user selected sort

* refactor: use listview to render shared albums

* refactor: rename to AlbumSortByOptions

* refactor: remove filtering inside sort functions

* font size

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-07 18:14:09 +00:00
Alex
2e59b07cc6 feat(mobile): handle backup iCloud asset (#5508)
* feat(mobile): handle backup iCloud asset

* additional state

* Download progress

* Added a separate page for backup options

* handle ingore iCloud asset upload'

* fix init backup service

* PR feedback

* fix negative count

* get file title
2023-12-07 09:53:15 -06:00
Fynn Petersen-Frey
c25556bb08 feat(mobile): unify asset grid multiselect actions (#5407)
* feat(mobile): unify asset grid multiselect actions

* add favorite & archive page

* show edit date&place on main photos screen

* Reposition exit button

* Sort favorite with the same order as other view

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-07 09:38:22 -06:00
dependabot[bot]
b9a9a3956c chore(deps): bump actions/setup-python from 4 to 5 (#5528)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 08:14:19 -05:00
shenlong
e1739ac4fc fix(mobile): allow editing asset dates in the future (#5522)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-06 20:04:03 -06:00
martin
8736c77f7a fix(web): align all edit buttons and not correctly rounded buttons on detail-panel (#5524)
* fix: align all pencils

* fix: format
2023-12-06 20:03:28 -06:00
James Keane
338a028185 fix(server): awaitsendFile (#5515)
Fixes the intermittent EPIPE errors that myself and others are seeing.

By explicitly returning a promise we ensure the caller correctly waits until the `sendFile` is complete before potentially closing or cleaning the socket. This is the most likely bug that would cause EPIPE errors.

Fix was confirmed on a live system -- would benefit from a unit test
though.
2023-12-06 20:51:51 -05:00
shenlong
e2d0e944eb chore(renovate): ignore openapi pubspec (#5521)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-06 14:21:57 -06:00
shenlong
f53b70571b fix: notify mobile app when live photos are linked (#5504)
* fix(mobile): album thumbnail list tile overflow on large album title

* fix: notify clients about live photo linked event

* refactor: notify clients during meta extraction

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-06 08:56:09 -06:00
renovate[bot]
2814de4420 chore(deps): update dependency vite to v4.5.1 [security] (#5513)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-05 22:14:39 -05:00
martin
024fe1141b fix(web): background when re-assigning faces (#5512) 2023-12-05 23:05:22 +00:00
shenlong
086a957a2b feat(mobile): edit date time & location (#5461)
* chore: text correction

* fix: update activities stat only when the widget is mounted

* feat(mobile): edit date time

* feat(mobile): edit location

* chore(build): update gradle wrapper - 7.6.3

* style: dropdownmenu styling

* style: wrap locationpicker in singlechildscrollview

* test: add unit test for getTZAdjustedTimeAndOffset

* pr changes

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-05 13:34:37 -06:00
Alex
84c5b08c25 feat(web): UI/UX improvement for date time edit form (#5505) 2023-12-05 14:16:37 -05:00
renovate[bot]
7e8488694d chore(deps): update web (#5502)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-05 11:20:07 -06:00
renovate[bot]
231b89c9c0 chore(deps): update postgres docker digest to 6dfee32 (#5492)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-05 10:17:33 -06:00
shenlong
d5f6584e1d fix(mobile): use zoomedpagetransition for galleryvieweroute (#5495)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-05 09:45:04 -06:00
martin
7702560b12 feat(web): re-assign person faces (2) (#4949)
* feat: unassign person faces

* multiple improvements

* chore: regenerate api

* feat: improve face interactions in photos

* fix: tests

* fix: tests

* optimize

* fix: wrong assignment on complex-multiple re-assignments

* fix: thumbnails with large photos

* fix: complex reassign

* fix: don't send people with faces

* fix: person thumbnail generation

* chore: regenerate api

* add tess

* feat: face box even when zoomed

* fix: change feature photo

* feat: make the blue icon hoverable

* chore: regenerate api

* feat: use websocket

* fix: loading spinner when clicking on the done button

* fix: use the svelte way

* fix: tests

* simplify

* fix: unused vars

* fix: remove unused code

* fix: add migration

* chore: regenerate api

* ci: add unit tests

* chore: regenerate api

* feat: if a new person is created for a face and the server takes more than 15 seconds to generate the person thumbnail, don't wait for it

* reorganize

* chore: regenerate api

* feat: global edit

* pr feedback

* pr feedback

* simplify

* revert test

* fix: face generation

* fix: tests

* fix: face generation

* fix merge

* feat: search names in unmerge face selector modal

* fix: merge face selector

* simplify feature photo generation

* fix: change endpoint

* pr feedback

* chore: fix merge

* chore: fix merge

* fix: tests

* fix: edit & hide buttons

* fix: tests

* feat: show if person is hidden

* feat: rename face to person

* feat: split in new panel

* copy-paste-error

* pr feedback

* fix: feature photo

* do not leak faces

* fix: unmerge modal

* fix: merge modal event

* feat(server): remove duplicates

* fix: title for image thumbnails

* fix: disable side panel when there's no face until next PR

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-05 09:43:15 -06:00
Clement Ong
982183600d feat(web): clear failed jobs (#5423)
* add clear failed jobs button

* refactor: clean up code

* chore: open api

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-12-05 02:07:20 +00:00
Dan Taylor
933c24ea6f fix(web): delete modal z-index (#5416)
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-12-05 01:32:33 +00:00
Jason Rasmussen
05e9697dff fix(web): runtime issue (#5493) 2023-12-04 20:29:35 -05:00
renovate[bot]
259700c45f chore(deps): update mambaorg/micromamba:bookworm-slim docker digest to e296d47 (#5487)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 19:37:08 -05:00
Jason Rasmussen
22d79850f6 refactor(web): asset viewer actions (#5488) 2023-12-04 19:18:28 -05:00
renovate[bot]
56aed8246d chore(deps): update python:3.11-slim-bookworm docker digest to cc75851 (#5462)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 13:26:41 -06:00
Quek
ca1be71bca fix(mobile): new album icon has different height to existing album cover (#5422) 2023-12-04 13:26:17 -06:00
martin
6111bf157e fix(web): stick action bar on search (#5459) 2023-12-04 10:24:19 -06:00
martin
2195730fa6 fix(web): keep url query parameters when swapping people (#5468) 2023-12-04 10:23:14 -06:00
waclaw66
1dc832d392 fix(web): new album title fix (#5467)
* new album title fix

* Naming

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-04 16:22:31 +00:00
PyKen
1a63d3837e fix(server): Return correct asset count in album (#5465)
* fix(server): Return correct asset count in album

* Update album.repository.sql

Add generated sql
2023-12-04 10:22:00 -06:00
Fynn Petersen-Frey
bdbaa166d9 fix(mobile): clear album provider on logout (#5477) 2023-12-04 10:21:05 -06:00
shenlong
812e67d55d fix(server): send upload_success notification only for non hidden assets (#5471)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-03 16:35:22 -06:00
martin
dfd6846deb fix(server): video orientation (#5455)
* fix: video orientation

* pr feedback
2023-12-03 16:34:23 -06:00
renovate[bot]
6d3421a505 chore(deps): update postgres:14-alpine docker digest to 6a0e352 (#5451)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-02 18:54:30 +00:00
renovate[bot]
ede9de146a chore(deps): update base-image to v20231201 (#5452)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-02 18:52:10 +00:00
renovate[bot]
6959cf689b chore(deps): update python:3.11-bookworm docker digest to ba7a7ac (#5438)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-02 18:35:23 +00:00
Alex
36ba48b8ae chore(web): toggle hide face in context menu (#5440) 2023-12-02 09:23:11 -06:00
Thomas
8a2b36ad55 docs(readme): correct wording and grammar in README_de_DE.md (#5447)
* fix wording and grammar in de_DE readme

* change Du+Eure to uppercase
2023-12-02 13:44:28 +01:00
Daniel Dietzler
6000c7f3bc update docs to use docker compose (#5446) 2023-12-02 12:48:49 +01:00
Michael Manganiello
5aa658de59 chore(server): Check asset permissions in bulk (#5329)
Modify Access repository, to evaluate `asset` permissions in bulk.
Queries have been validated to match what they currently generate for single ids.

Queries:

* `asset` album access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "albums" "AlbumEntity"
    LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets"
      ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id"
    LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets"
      ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId"
      AND "AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL
    LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"
      ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id"
    LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers"
      ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId"
      AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL
  WHERE
    (
      ("AlbumEntity"."ownerId" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2)
      OR ("AlbumEntity__AlbumEntity_sharedUsers"."id" = $3 AND "AlbumEntity__AlbumEntity_assets"."id" = $4)
      OR ("AlbumEntity"."ownerId" = $5 AND "AlbumEntity__AlbumEntity_assets"."livePhotoVideoId" = $6)
      OR ("AlbumEntity__AlbumEntity_sharedUsers"."id" = $7 AND "AlbumEntity__AlbumEntity_assets"."livePhotoVideoId" = $8)
    )
    AND "AlbumEntity"."deletedAt" IS NULL
)
LIMIT 1

-- After
SELECT
  "asset"."id" AS "assetId",
  "asset"."livePhotoVideoId" AS "livePhotoVideoId"
FROM "albums" "album"
  INNER JOIN "albums_assets_assets" "album_asset"
    ON "album_asset"."albumsId"="album"."id"
  INNER JOIN "assets" "asset"
    ON "asset"."id"="album_asset"."assetsId"
    AND "asset"."deletedAt" IS NULL
  LEFT JOIN "albums_shared_users_users" "album_sharedUsers"
    ON "album_sharedUsers"."albumsId"="album"."id"
  LEFT JOIN "users" "sharedUsers"
    ON "sharedUsers"."id"="album_sharedUsers"."usersId"
    AND "sharedUsers"."deletedAt" IS NULL
WHERE
  (
    "album"."ownerId" = $1
    OR "sharedUsers"."id" = $2
  )
  AND (
    "asset"."id" IN ($3, $4)
    OR "asset"."livePhotoVideoId" IN ($5, $6)
  )
  AND "album"."deletedAt" IS NULL
```

* `asset` owner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "assets" "AssetEntity"
  WHERE
    "AssetEntity"."id" = $1
    AND "AssetEntity"."ownerId" = $2
)
LIMIT 1

-- After
SELECT
  "AssetEntity"."id" AS "AssetEntity_id"
FROM "assets" "AssetEntity"
WHERE
  "AssetEntity"."id" IN ($1, $2)
  AND "AssetEntity"."ownerId" = $3
```

* `asset` partner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "partners" "PartnerEntity"
    LEFT JOIN "users" "PartnerEntity__PartnerEntity_sharedWith"
      ON "PartnerEntity__PartnerEntity_sharedWith"."id"="PartnerEntity"."sharedWithId"
      AND "PartnerEntity__PartnerEntity_sharedWith"."deletedAt" IS NULL
    LEFT JOIN "users" "PartnerEntity__PartnerEntity_sharedBy"
      ON "PartnerEntity__PartnerEntity_sharedBy"."id"="PartnerEntity"."sharedById"
      AND "PartnerEntity__PartnerEntity_sharedBy"."deletedAt" IS NULL
    LEFT JOIN "assets" "0aabe9f4a62b794e2c24a074297e534f51a4ac6c"
      ON "0aabe9f4a62b794e2c24a074297e534f51a4ac6c"."ownerId"="PartnerEntity__PartnerEntity_sharedBy"."id"
      AND "0aabe9f4a62b794e2c24a074297e534f51a4ac6c"."deletedAt" IS NULL
    LEFT JOIN "users" "PartnerEntity__sharedBy"
      ON "PartnerEntity__sharedBy"."id"="PartnerEntity"."sharedById"
      AND "PartnerEntity__sharedBy"."deletedAt" IS NULL
    LEFT JOIN "users" "PartnerEntity__sharedWith"
      ON "PartnerEntity__sharedWith"."id"="PartnerEntity"."sharedWithId"
      AND "PartnerEntity__sharedWith"."deletedAt" IS NULL
  WHERE
    "PartnerEntity__PartnerEntity_sharedWith"."id" = $1
    AND "0aabe9f4a62b794e2c24a074297e534f51a4ac6c"."id" = $2
)
LIMIT 1

-- After
SELECT
  "asset"."id" AS "assetId"
FROM "partners" "partner"
  INNER JOIN "users" "sharedBy"
    ON "sharedBy"."id"="partner"."sharedById"
    AND "sharedBy"."deletedAt" IS NULL
  INNER JOIN "assets" "asset"
    ON "asset"."ownerId"="sharedBy"."id"
    AND "asset"."deletedAt" IS NULL
WHERE
  "partner"."sharedWithId" = $1
  AND "asset"."id" IN ($2, $3)
```

* `asset` shared link access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "shared_links" "SharedLinkEntity"
    LEFT JOIN "albums" "SharedLinkEntity__SharedLinkEntity_album"
      ON "SharedLinkEntity__SharedLinkEntity_album"."id"="SharedLinkEntity"."albumId"
      AND "SharedLinkEntity__SharedLinkEntity_album"."deletedAt" IS NULL
    LEFT JOIN "albums_assets_assets" "760f12c00d97bdcec1ce224d1e3bf449859942b6"
      ON "760f12c00d97bdcec1ce224d1e3bf449859942b6"."albumsId"="SharedLinkEntity__SharedLinkEntity_album"."id"
    LEFT JOIN "assets" "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"
      ON "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."id"="760f12c00d97bdcec1ce224d1e3bf449859942b6"."assetsId"
      AND "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deletedAt" IS NULL
    LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"
      ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId"="SharedLinkEntity"."id"
    LEFT JOIN "assets" "SharedLinkEntity__SharedLinkEntity_assets"
      ON "SharedLinkEntity__SharedLinkEntity_assets"."id"="SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."assetsId"
      AND "SharedLinkEntity__SharedLinkEntity_assets"."deletedAt" IS NULL
  WHERE (
    ("SharedLinkEntity"."id" = $1 AND "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."id" = $2)
    OR ("SharedLinkEntity"."id" = $3 AND "SharedLinkEntity__SharedLinkEntity_assets"."id" = $4)
    OR ("SharedLinkEntity"."id" = $5 AND "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."livePhotoVideoId" = $6)
    OR ("SharedLinkEntity"."id" = $7 AND "SharedLinkEntity__SharedLinkEntity_assets"."livePhotoVideoId" = $8)
  )
)
LIMIT 1

-- After
SELECT
  "assets"."id" AS "assetId",
  "assets"."livePhotoVideoId" AS "assetLivePhotoVideoId",
  "albumAssets"."id" AS "albumAssetId",
  "albumAssets"."livePhotoVideoId" AS "albumAssetLivePhotoVideoId"
FROM "shared_links" "sharedLink"
  LEFT JOIN "albums" "album"
    ON "album"."id"="sharedLink"."albumId"
    AND "album"."deletedAt" IS NULL
  LEFT JOIN "shared_link__asset" "assets_sharedLink"
    ON "assets_sharedLink"."sharedLinksId"="sharedLink"."id"
  LEFT JOIN "assets" "assets"
    ON "assets"."id"="assets_sharedLink"."assetsId"
    AND "assets"."deletedAt" IS NULL
  LEFT JOIN "albums_assets_assets" "album_albumAssets"
    ON "album_albumAssets"."albumsId"="album"."id"
  LEFT JOIN "assets" "albumAssets"
    ON "albumAssets"."id"="album_albumAssets"."assetsId"
    AND "albumAssets"."deletedAt" IS NULL
WHERE
  "sharedLink"."id" = $1
  AND (
    "assets"."id" IN ($2, $3)
    OR "albumAssets"."id" IN ($4, $5)
    OR "assets"."livePhotoVideoId" IN ($6, $7)
    OR "albumAssets"."livePhotoVideoId" IN ($8, $9)
  )
```
2023-12-02 02:56:41 +00:00
Dan Taylor
6673f1eb24 fix(web): status box rendering (#5410)
* fix(web): status box rendering

* Syntax improvement for api import

Co-authored-by: martin <74269598+martabal@users.noreply.github.com>

---------

Co-authored-by: Daniel Taylor <daniel.k.taylor1@gmail.com>
Co-authored-by: martin <74269598+martabal@users.noreply.github.com>
2023-12-01 20:26:48 -06:00
Andrew Brock
a02e91169d feat(web): Allow showing hidden people in image asset details view (#5420)
* Allow showing hidden people in image asset details view

This makes it possible to easily find people/faces that have accidentally been hidden.
Unhiding them still requires clicking on the person to go to their page to unhide them.

* Update web/src/lib/components/asset-viewer/detail-panel.svelte

Co-authored-by: martin <74269598+martabal@users.noreply.github.com>

---------

Co-authored-by: brokeh <git@brocky.net>
Co-authored-by: martin <74269598+martabal@users.noreply.github.com>
2023-12-01 20:17:05 -06:00
martin
ec92608024 fix(web): disable metadata edit if user is not owner (#5415)
* fix(web): disable metadata edit if user is not owner

* pr feedback

* pr feedback

* get data from page data

* fix: better representation

* feat: warn user if there's issues with the selected assets

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-01 14:58:24 -06:00
Andrew Brock
5a50d32748 chore: make running local dev instance on Windows work regardless of git config (#5419)
The only issue was that line endings of shell scripts may be \r\n when cloned on Windows with autocrlf and shells don't like that.

Co-authored-by: brokeh <git@brocky.net>
2023-12-01 20:30:34 +00:00
renovate[bot]
cbdcbd3ab4 chore(deps): update python:3.11-slim-bookworm docker digest to 8f82989 (#5435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 20:24:50 +00:00
renovate[bot]
cb00d45e3d chore(deps): update python:3.11-bookworm docker digest to 47c1829 (#5434)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 20:13:23 +00:00
Jason Rasmussen
2b2b1bba63 chore(deps): base image min age (#5433) 2023-12-01 14:42:21 -05:00
renovate[bot]
031420bc78 chore(deps): update base-image to v20231130 (#5431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 14:27:42 -05:00
renovate[bot]
387faa13d5 chore(deps): update redis:6.2-alpine docker digest to 60e49e2 (#5430)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 19:25:21 +00:00
renovate[bot]
6979d43650 chore(deps): update postgres:14-alpine docker digest to 5491670 (#5429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 19:25:08 +00:00
renovate[bot]
af8bb132d0 chore(deps): update dependency @types/node to v20.10.1 (#5427)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 18:44:22 +00:00
renovate[bot]
40964187bb chore(deps): update python:3.11-slim-bookworm docker digest to 23f5220 (#5388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 18:36:54 +00:00
renovate[bot]
fe3d951f26 chore(deps): update python:3.11-bookworm docker digest to c56b0c6 (#5387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 18:36:30 +00:00
Jason Rasmussen
6e365b37db fix(server): immich command (#5408)
* fix: immich command

* chore: use absolute paths
2023-11-30 14:59:47 -06:00
Jason Rasmussen
5e55a17b2a chore(server): sql versioning (#5346)
* chore(server): sql versioning

* chore: always add newline to end of file

* refactor: generator

* chore: pr feedback

* chore: pr feedback
2023-11-30 10:10:30 -05:00
dependabot[bot]
ffecfbe075 chore(deps): bump actions/setup-java from 3 to 4 (#5405)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 3 to 4.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-30 09:03:24 -05:00
YFrendo
644e52b153 feat: Edit metadata (#5066)
* chore: rebase and clean-up

* feat: sync description, add e2e tests

* feat: simplify web code

* chore: unit tests

* fix: linting

* Bug fix with the arrows key

* timezone typeahead filter

timezone typeahead filter

* small stlying

* format fix

* Bug fix in the map selection

Bug fix in the map selection

* Websocket basic

Websocket basic

* Update metadata visualisation through the websocket

* Update timeline

* fix merge

* fix web

* fix web

* maplibre system

* format fix

* format fix

* refactor: clean up

* Fix small bug in the hour/timezone

* Don't diplay modify for readOnly asset

* Add log in case of failure

* Formater + try/catch error

* Remove everything related to websocket

* Revert "Remove everything related to websocket"

This reverts commit 14bcb9e1e4.

* remove notification

* fix test

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-11-30 03:52:28 +00:00
martin
b396e0eee3 fix(web): whitespace in person name (#5401)
* fix(web): whitespace in person name

* pr feedback
2023-11-29 21:08:54 -06:00
285 changed files with 13190 additions and 2661 deletions

2
.gitattributes vendored
View File

@@ -17,3 +17,5 @@ web/src/api/open-api/**/*.md -diff -merge
web/src/api/open-api/**/*.md linguist-generated=true
web/src/api/open-api/**/*.ts -diff -merge
web/src/api/open-api/**/*.ts linguist-generated=true
*.sh text eol=lf

View File

@@ -35,7 +35,7 @@ jobs:
with:
ref: ${{ steps.get-ref.outputs.ref }}
- uses: actions/setup-java@v3
- uses: actions/setup-java@v4
with:
distribution: "zulu"
java-version: "12.x"

View File

@@ -167,7 +167,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
with:
python-version: 3.11
cache: "poetry"
@@ -209,11 +209,11 @@ jobs:
exit 1
generated-typeorm-migrations-up-to-date:
name: TypeORM Migrations
name: TypeORM Checks
runs-on: ubuntu-latest
services:
postgres:
image: postgres@sha256:71da05df8c4f1e1bac9b92ebfba2a0eeb183f6ac6a972fd5e55e8146e29efe9c
image: postgres@sha256:6dfee32131933ab4ca25a00360c3f427fdc134de56f9a90c6c9a4956b48aea85
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
@@ -236,7 +236,7 @@ jobs:
- name: Install server dependencies
run: npm ci
- name: Build the
- name: Build the app
run: npm run build
- name: Run existing migrations
@@ -252,13 +252,30 @@ jobs:
with:
files: |
server/src/infra/migrations/
- name: Verify files have not changed
- name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
run: |
echo "ERROR: Generated files not up to date!"
echo "ERROR: Generated migration files not up to date!"
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
exit 1
- name: Run SQL generation
run: npm run sql:generate
- name: Find file changes
uses: tj-actions/verify-changed-files@v13.1
id: verify-changed-sql-files
with:
files: |
server/src/infra/sql
- name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
run: |
echo "ERROR: Generated SQL files not up to date!"
echo "Changed files: ${{ steps.verify-changed-sql-files.outputs.changed_files }}"
exit 1
# mobile-integration-tests:
# name: Run mobile end-to-end integration tests
# runs-on: macos-latest

View File

@@ -26,7 +26,10 @@ prod-scale:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
api:
cd ./server && npm run api:generate
npm --prefix server run api:generate
sql:
npm --prefix server run sql:generate
attach-server:
docker exec -it docker_immich-server_1 sh

View File

@@ -11,7 +11,7 @@
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login mit eigener URL">
</p>
<h3 align="center">Immich - Hoch performante, selbst gehostete Backup Lösung für Fotos und Videos</h3>
<h3 align="center">Immich - Hoch performante, selbst gehostete Backup-Lösung für Fotos und Videos</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Haupt-Screenshot">
@@ -32,10 +32,10 @@
## Warnung
- ⚠️ Das Projekt befindet sich unter **sehr aktiver** Entwicklung.
- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung.
- ⚠️ Erwarte Fehler und Änderungen mit Breaking-Changes.
- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.**
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup Regel für deine wertvollen Fotos und Videos!
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
## Inhalt
@@ -54,9 +54,9 @@ Die Hauptdokumentation, inklusive Installationsanleitungen, ist unter https://im
## Demo
Die Web-Demo kannst du unter https://demo.immich.app finden.
Die Web-Demo kannst Du unter https://demo.immich.app finden.
Für die Handy-App kannst du `https://demo.immich.app/api` als `Server Endpoint URL` angeben.
Für die Handy-App kannst Du `https://demo.immich.app/api` als `Server Endpoint URL` angeben.
```bash title="Demo Credential"
Die Anmeldedaten
@@ -73,8 +73,8 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Funktionen | Mobil | Web |
| ---------------------------------------------------- | ------ | ----- |
| Fotos & Videos hochladen und ansehen | Ja | Ja |
| Automatisches Backup wenn die App offen ist | Ja | n. a. |
| Selektive Auswahl von Alben zum Backup | Ja | n. a. |
| Automatisches Backup wenn die App geöffnet ist | Ja | n. a. |
| Selektive Auswahl von Alben zum Sichern | Ja | n. a. |
| Fotos und Videos auf das Gerät herunterladen | Ja | Ja |
| Unterstützt mehrere Benutzer | Ja | Ja |
| Album und geteilte Alben | Ja | Ja |
@@ -82,7 +82,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Unterstützt RAW Formate | Ja | Ja |
| Metadaten anzeigen (EXIF, Karte) | Ja | Ja |
| Suchen nach Metadaten, Objekten, Gesichtern und CLIP | Ja | Ja |
| Administrative Funktionen (Nutzerverwaltung) | Nein | Ja |
| Administrative Funktionen (Benutzerverwaltung) | Nein | Ja |
| Backup im Hintergrund | Ja | n. a. |
| Virtuelles Scrollen | Ja | Ja |
| OAuth Unterstützung | Ja | Ja |
@@ -101,22 +101,22 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
## Unterstütze das Projekt
Ich habe mich diesem Projekt verpflichtet und werde nicht aufgeben. Ich werde die Dokumentation weiter aktualisieren, neue Funktionen hinzufügen und Fehler beheben. Allerdings kann ich das nicht alleine schaffen. Daher brauche ich Eure Unterstützung mir zusätzliche Motivation zu geben um weiterzumachen.
Ich habe mich diesem Projekt verpflichtet und werde nicht aufgeben. Ich werde die Dokumentation weiter aktualisieren, neue Funktionen hinzufügen und Fehler beheben. Allerdings kann ich das nicht alleine schaffen. Daher brauche ich Eure Unterstützung, um mir zusätzliche Motivation zu geben, weiterzumachen.
Wie unsere Gastgeber in der [selfhosted.show - In der Episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) gesagt haben, ist dies ein riesiges Unterfangen, welchem das Team und ich uns annehmen. In Zukunft würde ich liebend gerne Vollzeit an dem Projekt arbeiten und bitte daher um Eure Unterstützung.
Wenn Du denkst, dass dies die richtige Sache ist und dich selbst die App für eine längere Zeit nutzen siehst, dann denke bitte darüber nach das Projekt mit einer der unten aufgelisteten Optionen zu unterstützen.
Wenn Du denkst, dass dies die richtige Sache ist und dich selbst die App für eine längere Zeit nutzen siehst, dann denke bitte darüber nach, das Projekt mit einer der unten aufgelisteten Optionen zu unterstützen.
### Spenden
- [Monatliche Spende](https://github.com/sponsors/alextran1502) via GitHub Sponsors
- [Einmalige Spende](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
- [Monatliche Spende](https://github.com/sponsors/immich-app) via GitHub Sponsors
- [Einmalige Spende](https://github.com/sponsors/immich-app?frequency=one-time&sponsor=immich-app) via GitHub Sponsors
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
## Unterstützer
## Mitwirkende
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

12
cli/package-lock.json generated
View File

@@ -1604,9 +1604,9 @@
}
},
"node_modules/@types/node": {
"version": "20.10.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
"version": "20.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz",
"integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -7990,9 +7990,9 @@
}
},
"@types/node": {
"version": "20.10.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
"version": "20.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz",
"integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.89.0
* The version of the OpenAPI document: 1.90.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -447,6 +447,12 @@ export interface AssetBulkDeleteDto {
* @interface AssetBulkUpdateDto
*/
export interface AssetBulkUpdateDto {
/**
*
* @type {string}
* @memberof AssetBulkUpdateDto
*/
'dateTimeOriginal'?: string;
/**
*
* @type {Array<string>}
@@ -465,6 +471,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
/**
*
* @type {number}
* @memberof AssetBulkUpdateDto
*/
'latitude'?: number;
/**
*
* @type {number}
* @memberof AssetBulkUpdateDto
*/
'longitude'?: number;
/**
*
* @type {boolean}
@@ -568,6 +586,142 @@ export const AssetBulkUploadCheckResultReasonEnum = {
export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum];
/**
*
* @export
* @interface AssetFaceResponseDto
*/
export interface AssetFaceResponseDto {
/**
*
* @type {number}
* @memberof AssetFaceResponseDto
*/
'boundingBoxX1': number;
/**
*
* @type {number}
* @memberof AssetFaceResponseDto
*/
'boundingBoxX2': number;
/**
*
* @type {number}
* @memberof AssetFaceResponseDto
*/
'boundingBoxY1': number;
/**
*
* @type {number}
* @memberof AssetFaceResponseDto
*/
'boundingBoxY2': number;
/**
*
* @type {string}
* @memberof AssetFaceResponseDto
*/
'id': string;
/**
*
* @type {number}
* @memberof AssetFaceResponseDto
*/
'imageHeight': number;
/**
*
* @type {number}
* @memberof AssetFaceResponseDto
*/
'imageWidth': number;
/**
*
* @type {PersonResponseDto}
* @memberof AssetFaceResponseDto
*/
'person': PersonResponseDto | null;
}
/**
*
* @export
* @interface AssetFaceUpdateDto
*/
export interface AssetFaceUpdateDto {
/**
*
* @type {Array<AssetFaceUpdateItem>}
* @memberof AssetFaceUpdateDto
*/
'data': Array<AssetFaceUpdateItem>;
}
/**
*
* @export
* @interface AssetFaceUpdateItem
*/
export interface AssetFaceUpdateItem {
/**
*
* @type {string}
* @memberof AssetFaceUpdateItem
*/
'assetId': string;
/**
*
* @type {string}
* @memberof AssetFaceUpdateItem
*/
'personId': string;
}
/**
*
* @export
* @interface AssetFaceWithoutPersonResponseDto
*/
export interface AssetFaceWithoutPersonResponseDto {
/**
*
* @type {number}
* @memberof AssetFaceWithoutPersonResponseDto
*/
'boundingBoxX1': number;
/**
*
* @type {number}
* @memberof AssetFaceWithoutPersonResponseDto
*/
'boundingBoxX2': number;
/**
*
* @type {number}
* @memberof AssetFaceWithoutPersonResponseDto
*/
'boundingBoxY1': number;
/**
*
* @type {number}
* @memberof AssetFaceWithoutPersonResponseDto
*/
'boundingBoxY2': number;
/**
*
* @type {string}
* @memberof AssetFaceWithoutPersonResponseDto
*/
'id': string;
/**
*
* @type {number}
* @memberof AssetFaceWithoutPersonResponseDto
*/
'imageHeight': number;
/**
*
* @type {number}
* @memberof AssetFaceWithoutPersonResponseDto
*/
'imageWidth': number;
}
/**
*
* @export
@@ -824,10 +978,10 @@ export interface AssetResponseDto {
'ownerId': string;
/**
*
* @type {Array<PersonResponseDto>}
* @type {Array<PersonWithFacesResponseDto>}
* @memberof AssetResponseDto
*/
'people'?: Array<PersonResponseDto>;
'people'?: Array<PersonWithFacesResponseDto>;
/**
*
* @type {boolean}
@@ -1654,6 +1808,19 @@ export interface ExifResponseDto {
*/
'timeZone'?: string | null;
}
/**
*
* @export
* @interface FaceDto
*/
export interface FaceDto {
/**
*
* @type {string}
* @memberof FaceDto
*/
'id': string;
}
/**
*
* @export
@@ -1767,7 +1934,8 @@ export const JobCommand = {
Start: 'start',
Pause: 'pause',
Resume: 'resume',
Empty: 'empty'
Empty: 'empty',
ClearFailed: 'clear-failed'
} as const;
export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
@@ -2545,6 +2713,49 @@ export interface PersonUpdateDto {
*/
'name'?: string;
}
/**
*
* @export
* @interface PersonWithFacesResponseDto
*/
export interface PersonWithFacesResponseDto {
/**
*
* @type {string}
* @memberof PersonWithFacesResponseDto
*/
'birthDate': string | null;
/**
*
* @type {Array<AssetFaceWithoutPersonResponseDto>}
* @memberof PersonWithFacesResponseDto
*/
'faces': Array<AssetFaceWithoutPersonResponseDto>;
/**
*
* @type {string}
* @memberof PersonWithFacesResponseDto
*/
'id': string;
/**
*
* @type {boolean}
* @memberof PersonWithFacesResponseDto
*/
'isHidden': boolean;
/**
*
* @type {string}
* @memberof PersonWithFacesResponseDto
*/
'name': string;
/**
*
* @type {string}
* @memberof PersonWithFacesResponseDto
*/
'thumbnailPath': string;
}
/**
*
* @export
@@ -4137,6 +4348,12 @@ export interface UpdateAlbumDto {
* @interface UpdateAssetDto
*/
export interface UpdateAssetDto {
/**
*
* @type {string}
* @memberof UpdateAssetDto
*/
'dateTimeOriginal'?: string;
/**
*
* @type {string}
@@ -4155,6 +4372,18 @@ export interface UpdateAssetDto {
* @memberof UpdateAssetDto
*/
'isFavorite'?: boolean;
/**
*
* @type {number}
* @memberof UpdateAssetDto
*/
'latitude'?: number;
/**
*
* @type {number}
* @memberof UpdateAssetDto
*/
'longitude'?: number;
}
/**
*
@@ -11312,6 +11541,233 @@ export class AuthenticationApi extends BaseAPI {
}
/**
* FaceApi - axios parameter creator
* @export
*/
export const FaceApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getFaces: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getFaces', 'id', id)
const localVarPath = `/face`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (id !== undefined) {
localVarQueryParameter['id'] = id;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {FaceDto} faceDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
reassignFacesById: async (id: string, faceDto: FaceDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('reassignFacesById', 'id', id)
// verify required parameter 'faceDto' is not null or undefined
assertParamExists('reassignFacesById', 'faceDto', faceDto)
const localVarPath = `/face/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* FaceApi - functional programming interface
* @export
*/
export const FaceApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = FaceApiAxiosParamCreator(configuration)
return {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getFaces(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetFaceResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getFaces(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {FaceDto} faceDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async reassignFacesById(id: string, faceDto: FaceDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* FaceApi - factory interface
* @export
*/
export const FaceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = FaceApiFp(configuration)
return {
/**
*
* @param {FaceApiGetFacesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetFaceResponseDto>> {
return localVarFp.getFaces(requestParameters.id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for getFaces operation in FaceApi.
* @export
* @interface FaceApiGetFacesRequest
*/
export interface FaceApiGetFacesRequest {
/**
*
* @type {string}
* @memberof FaceApiGetFaces
*/
readonly id: string
}
/**
* Request parameters for reassignFacesById operation in FaceApi.
* @export
* @interface FaceApiReassignFacesByIdRequest
*/
export interface FaceApiReassignFacesByIdRequest {
/**
*
* @type {string}
* @memberof FaceApiReassignFacesById
*/
readonly id: string
/**
*
* @type {FaceDto}
* @memberof FaceApiReassignFacesById
*/
readonly faceDto: FaceDto
}
/**
* FaceApi - object-oriented interface
* @export
* @class FaceApi
* @extends {BaseAPI}
*/
export class FaceApi extends BaseAPI {
/**
*
* @param {FaceApiGetFacesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof FaceApi
*/
public getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig) {
return FaceApiFp(this.configuration).getFaces(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof FaceApi
*/
public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) {
return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* JobApi - axios parameter creator
* @export
@@ -13143,6 +13599,44 @@ export class PartnerApi extends BaseAPI {
*/
export const PersonApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createPerson: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {boolean} [withHidden]
@@ -13402,6 +13896,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {AssetFaceUpdateDto} assetFaceUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
reassignFaces: async (id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('reassignFaces', 'id', id)
// verify required parameter 'assetFaceUpdateDto' is not null or undefined
assertParamExists('reassignFaces', 'assetFaceUpdateDto', assetFaceUpdateDto)
const localVarPath = `/person/{id}/reassign`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(assetFaceUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
@@ -13504,6 +14046,15 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createPerson(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createPerson(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {boolean} [withHidden]
@@ -13565,6 +14116,17 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {AssetFaceUpdateDto} assetFaceUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async reassignFaces(id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFaces(id, assetFaceUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
@@ -13596,6 +14158,14 @@ export const PersonApiFp = function(configuration?: Configuration) {
export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = PersonApiFp(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createPerson(options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
return localVarFp.createPerson(options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
@@ -13650,6 +14220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiReassignFacesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
@@ -13762,6 +14341,27 @@ export interface PersonApiMergePersonRequest {
readonly mergePersonDto: MergePersonDto
}
/**
* Request parameters for reassignFaces operation in PersonApi.
* @export
* @interface PersonApiReassignFacesRequest
*/
export interface PersonApiReassignFacesRequest {
/**
*
* @type {string}
* @memberof PersonApiReassignFaces
*/
readonly id: string
/**
*
* @type {AssetFaceUpdateDto}
* @memberof PersonApiReassignFaces
*/
readonly assetFaceUpdateDto: AssetFaceUpdateDto
}
/**
* Request parameters for updatePeople operation in PersonApi.
* @export
@@ -13804,6 +14404,16 @@ export interface PersonApiUpdatePersonRequest {
* @extends {BaseAPI}
*/
export class PersonApi extends BaseAPI {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public createPerson(options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).createPerson(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
@@ -13870,6 +14480,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiReassignFacesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.89.0
* The version of the OpenAPI document: 1.90.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.89.0
* The version of the OpenAPI document: 1.90.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.89.0
* The version of the OpenAPI document: 1.90.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.89.0
* The version of the OpenAPI document: 1.90.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -108,11 +108,11 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
database:
container_name: immich_postgres
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
env_file:
- .env
environment:

View File

@@ -65,12 +65,12 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
restart: always
database:
container_name: immich_postgres
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
env_file:
- .env
environment:

View File

@@ -23,7 +23,7 @@ services:
- database
database:
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
command: -c fsync=off
environment:
POSTGRES_PASSWORD: postgres

View File

@@ -69,12 +69,12 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
restart: always
database:
container_name: immich_postgres
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
env_file:
- .env
environment:

View File

@@ -144,7 +144,7 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
From the directory you created in Step 1, (which should now contain your customized `docker-compose.yml` and `.env` files) run `docker-compose up -d`.
```bash title="Start the containers using docker compose command"
docker-compose up -d # or `docker compose up -d` based on your docker-compose version
docker compose up -d
```
:::tip
@@ -162,7 +162,7 @@ If `IMMICH_VERSION` is set, it will need to be updated to the latest or desired
When a new version of Immich is [released](https://github.com/immich-app/immich/releases), the application can be upgraded with the following commands, run in the directory with the `docker-compose.yml` file:
```bash title="Upgrade Immich"
docker-compose pull && docker-compose up -d # Or `docker compose up -d`
docker compose pull && docker compose up -d
```
:::caution Automatic Updates

View File

@@ -1,4 +1,4 @@
FROM python:3.11-bookworm@sha256:e5a1b0a194a5fbf94f6e350b31c9a508723f9eeb2f9e9e32c3b65df8520a40cc as builder
FROM python:3.11-bookworm@sha256:ba7a7ac30c38e119c4304f98ef0e188f90f4f67a958bb6899da9defb99bfb471 as builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
@@ -13,7 +13,7 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
FROM python:3.11-slim-bookworm@sha256:1bc6a3e9356d64ea632791653bc71a56340e8741dab66434ab2739ebf6aed29d
FROM python:3.11-slim-bookworm@sha256:cc758519481092eb5a4a5ab0c1b303e288880d59afc601958d19e95b300bc86b
RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*

View File

@@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:d20c621f3ae42f50f380166b15b6c88b14fa62ab6ea188f2cef33451d64057c7 as builder
FROM mambaorg/micromamba:bookworm-slim@sha256:e296d47be09fc5d260eba9b191f60496f028a4f3ec41e8a14d48c0bae2c60244 as builder
ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.89.0"
version = "1.90.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

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

View File

@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip
distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80

View File

@@ -31,7 +31,6 @@
"app_bar_signout_dialog_content": "Are you sure you want to sign out?",
"app_bar_signout_dialog_ok": "Yes",
"app_bar_signout_dialog_title": "Sign out",
"archive_page_no_archived_assets": "No archived assets found",
"archive_page_title": "Archive ({})",
"asset_list_layout_settings_dynamic_layout_title": "Dynamic layout",
"asset_list_layout_settings_group_automatically": "Automatic",
@@ -139,11 +138,14 @@
"control_bottom_app_bar_create_new_album": "Create new album",
"control_bottom_app_bar_delete": "Delete",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_unfavorite": "Unfavorite",
"control_bottom_app_bar_share": "Share",
"control_bottom_app_bar_share_to": "Share To",
"control_bottom_app_bar_stack": "Stack",
"control_bottom_app_bar_unarchive": "Unarchive",
"control_bottom_app_bar_upload": "Upload",
"control_bottom_app_bar_edit_time": "Edit Date & Time",
"control_bottom_app_bar_edit_location": "Edit Location",
"create_album_page_untitled": "Untitled",
"create_shared_album_page_create": "Create",
"create_shared_album_page_share": "Share",
@@ -165,11 +167,11 @@
"exif_bottom_sheet_description": "Add Description...",
"exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "LOCATION",
"exif_bottom_sheet_location_add": "Add a location",
"experimental_settings_new_asset_list_subtitle": "Work in progress",
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_title": "Experimental",
"favorites_page_no_favorites": "No favorite assets found",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
@@ -193,9 +195,11 @@
"library_page_favorites": "Favorites",
"library_page_new_album": "New album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_created": "Created date",
"library_page_sort_last_modified": "Last modified",
"library_page_sort_most_recent_photo": "Most recent photo",
"library_page_sort_most_oldest_photo": "Oldest photo",
"library_page_sort_asset_count": "Number of assets",
"library_page_sort_title": "Album title",
"login_disabled": "Login has been disabled",
"login_form_api_exception": "API exception. Please check the server URL and try again.",
@@ -461,5 +465,18 @@
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack",
"scaffold_body_error_occured": "Error occured"
"scaffold_body_error_occurred": "Error occurred",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"action_common_cancel": "Cancel",
"action_common_update": "Update",
"edit_location_dialog_title": "Location",
"map_location_picker_page_use_location": "Use this location",
"location_picker_choose_on_map": "Choose on map",
"location_picker_latitude": "Latitude",
"location_picker_latitude_hint": "Enter your latitude here",
"location_picker_latitude_error": "Enter a valid latitude",
"location_picker_longitude": "Longitude",
"location_picker_longitude_hint": "Enter your longitude here",
"location_picker_longitude_error": "Enter a valid longitude"
}

View File

@@ -169,4 +169,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.11.3
COCOAPODS: 1.12.1

View File

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

View File

@@ -0,0 +1,36 @@
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:timezone/timezone.dart';
extension TZExtension on Asset {
/// Returns the created time of the asset from the exif info (if available) or from
/// the fileCreatedAt field, adjusted to the timezone value from the exif info along with
/// the timezone offset in [Duration]
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
DateTime dt = fileCreatedAt.toLocal();
if (exifInfo?.dateTimeOriginal != null) {
dt = exifInfo!.dateTimeOriginal!;
if (exifInfo?.timeZone != null) {
dt = dt.toUtc();
try {
final location = getLocation(exifInfo!.timeZone!);
dt = TZDateTime.from(dt, location);
} on LocationNotFoundException {
RegExp re = RegExp(
r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
caseSensitive: false,
);
final m = re.firstMatch(exifInfo!.timeZone!);
if (m != null) {
final duration = Duration(
hours: int.parse(m.group(1) ?? '0'),
minutes: int.parse(m.group(2) ?? '0'),
);
dt = dt.add(duration);
return (dt, duration);
}
}
}
}
return (dt, dt.timeZoneOffset);
}
}

View File

@@ -0,0 +1,4 @@
extension TZOffsetExtension on Duration {
String formatAsOffset() =>
"${isNegative ? '-' : '+'}${inHours.abs().toString().padLeft(2, '0')}:${inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
}

View File

@@ -95,7 +95,11 @@ class ActivityStatisticsNotifier extends StateNotifier<int> {
}
Future<void> fetchStatistics() async {
state = await _activityService.getStatistics(albumId, assetId: assetId);
final count =
await _activityService.getStatistics(albumId, assetId: assetId);
if (mounted) {
state = count;
}
}
Future<void> addActivity() async {

View File

@@ -2,11 +2,13 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> {
@@ -49,3 +51,24 @@ final albumProvider =
ref.watch(dbProvider),
);
});
final albumWatcher =
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
final db = ref.watch(dbProvider);
final a = await db.albums.get(albumId);
if (a != null) yield a;
await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
if (a != null) yield a;
}
});
final albumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(albumWatcher(albumId)).value;
if (album != null) {
final query =
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
}
return const Stream.empty();
});

View File

@@ -1,21 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
final albumDetailProvider =
StreamProvider.family<Album, int>((ref, albumId) async* {
final user = ref.watch(currentUserProvider);
if (user == null) return;
final AlbumService service = ref.watch(albumServiceProvider);
await for (final a in service.watchAlbum(albumId)) {
if (a == null) {
throw Exception("Album with ID=$albumId does not exist anymore!");
}
await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
yield a;
}
}
});

View File

@@ -0,0 +1,131 @@
import 'package:collection/collection.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/album.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'album_sort_by_options.provider.g.dart';
typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse);
class _AlbumSortHandlers {
const _AlbumSortHandlers._();
static const AlbumSortFn created = _sortByCreated;
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn title = _sortByTitle;
static List<Album> _sortByTitle(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.name);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn lastModified = _sortByLastModified;
static List<Album> _sortByLastModified(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.modifiedAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn assetCount = _sortByAssetCount;
static List<Album> _sortByAssetCount(List<Album> albums, bool isReverse) {
final sorted =
albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostRecent = _sortByMostRecent;
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
if (a.endDate != null && b.endDate != null) {
return a.endDate!.compareTo(b.endDate!);
}
if (a.endDate == null) return 1;
if (b.endDate == null) return -1;
return 0;
});
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostOldest = _sortByMostOldest;
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
if (a.startDate != null && b.startDate != null) {
return a.startDate!.compareTo(b.startDate!);
}
if (a.startDate == null) return 1;
if (b.startDate == null) return -1;
return 0;
});
return (isReverse ? sorted.reversed : sorted).toList();
}
}
// Store index allows us to re-arrange the values without affecting the saved prefs
enum AlbumSortMode {
title(1, "library_page_sort_title", _AlbumSortHandlers.title),
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount),
lastModified(
3,
"library_page_sort_last_modified",
_AlbumSortHandlers.lastModified,
),
created(0, "library_page_sort_created", _AlbumSortHandlers.created),
mostRecent(
2,
"library_page_sort_most_recent_photo",
_AlbumSortHandlers.mostRecent,
),
mostOldest(
5,
"library_page_sort_most_oldest_photo",
_AlbumSortHandlers.mostOldest,
);
final int storeIndex;
final String label;
final AlbumSortFn sortFn;
const AlbumSortMode(this.storeIndex, this.label, this.sortFn);
}
@riverpod
class AlbumSortByOptions extends _$AlbumSortByOptions {
@override
AlbumSortMode build() {
final sortOpt = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
return AlbumSortMode.values.firstWhere(
(e) => e.index == sortOpt,
orElse: () => AlbumSortMode.title,
);
}
void changeSortMode(AlbumSortMode sortOption) {
state = sortOption;
ref.watch(appSettingsServiceProvider).setSetting(
AppSettingsEnum.selectedAlbumSortOrder,
sortOption.storeIndex,
);
}
}
@riverpod
class AlbumSortOrder extends _$AlbumSortOrder {
@override
bool build() {
return ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
}
void changeSortDirection(bool isReverse) {
state = isReverse;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.selectedAlbumSortReverse, isReverse);
}
}

View File

@@ -0,0 +1,43 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'album_sort_by_options.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$albumSortByOptionsHash() =>
r'8d22fa8b7cbca2d3d7ed20a83bf00211dc948004';
/// See also [AlbumSortByOptions].
@ProviderFor(AlbumSortByOptions)
final albumSortByOptionsProvider =
AutoDisposeNotifierProvider<AlbumSortByOptions, AlbumSortMode>.internal(
AlbumSortByOptions.new,
name: r'albumSortByOptionsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$albumSortByOptionsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AlbumSortByOptions = AutoDisposeNotifier<AlbumSortMode>;
String _$albumSortOrderHash() => r'573dea45b4519e69386fc7104c72522e35713440';
/// See also [AlbumSortOrder].
@ProviderFor(AlbumSortOrder)
final albumSortOrderProvider =
AutoDisposeNotifierProvider<AlbumSortOrder, bool>.internal(
AlbumSortOrder.new,
name: r'albumSortOrderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$albumSortOrderHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AlbumSortOrder = AutoDisposeNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

@@ -0,0 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
final currentAlbumProvider = StateProvider<Album?>((ref) {
return null;
});

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -11,7 +10,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, Isar db, this._ref) : super([]) {
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
query.findAll().then((value) => state = value);
_streamSub = query.watch().listen((data) => state = data);
@@ -19,7 +18,6 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub;
final Ref _ref;
Future<Album?> createSharedAlbum(
String albumName,
@@ -68,15 +66,8 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
return result;
}
Future<bool> setActivityEnabled(Album album, bool activityEnabled) async {
final result =
await _albumService.setActivityEnabled(album, activityEnabled);
if (result) {
_ref.invalidate(albumDetailProvider(album.id));
}
return result;
Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
return _albumService.setActivityEnabled(album, activityEnabled);
}
@override
@@ -91,6 +82,5 @@ final sharedAlbumProvider =
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
ref,
);
});

View File

@@ -219,11 +219,6 @@ class AlbumService {
);
}
Stream<Album?> watchAlbum(int albumId) async* {
yield await _db.albums.get(albumId);
yield* _db.albums.watchObject(albumId);
}
Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
Iterable<Asset> assets,
Album album,
@@ -248,8 +243,12 @@ class AlbumService {
}
}
album.assets.addAll(successAssets);
await _db.writeTxn(() => album.assets.save());
await _db.writeTxn(() async {
await album.assets.update(link: successAssets);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.albums.put(a!);
});
return AddAssetsResponse(
alreadyInAlbum: duplicatedAssets,
@@ -359,8 +358,12 @@ class AlbumService {
ids: assets.map((asset) => asset.remoteId!).toList(),
),
);
album.assets.removeAll(assets);
await _db.writeTxn(() => album.assets.update(unlink: assets));
await _db.writeTxn(() async {
await album.assets.update(unlink: assets);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.albums.put(a!);
});
return true;
} catch (e) {
@@ -380,7 +383,12 @@ class AlbumService {
);
album.sharedUsers.remove(user);
await _db.writeTxn(() => album.sharedUsers.update(unlink: [user]));
await _db.writeTxn(() async {
await album.sharedUsers.update(unlink: [user]);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.albums.put(a!);
});
return true;
} catch (e) {

View File

@@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.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/modules/album/ui/add_to_album_sliverlist.dart';
@@ -63,8 +62,6 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
);
}
}
ref.invalidate(albumDetailProvider(album.id));
context.pop();
}

View File

@@ -1,6 +1,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/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
import 'package:immich_mobile/shared/models/album.dart';
@@ -21,33 +22,44 @@ class AddToAlbumSliverList extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumSortMode = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
final sortedAlbums = albumSortMode.sortFn(albums, albumSortIsReverse);
final sortedSharedAlbums =
albumSortMode.sortFn(sharedAlbums, albumSortIsReverse);
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: albums.length + (sharedAlbums.isEmpty ? 0 : 1),
(context, index) {
// Build shared expander
if (index == 0 && sharedAlbums.isNotEmpty) {
if (index == 0 && sortedSharedAlbums.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ExpansionTile(
title: Text('common_shared'.tr()),
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
leading: const Icon(Icons.group),
children: sharedAlbums
.map(
(album) => AlbumThumbnailListTile(
album: album,
onTap: enabled ? () => onAddToAlbum(album) : () {},
),
)
.toList(),
children: [
ListView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemCount: sortedSharedAlbums.length,
itemBuilder: (context, index) => AlbumThumbnailListTile(
album: sortedSharedAlbums[index],
onTap: enabled
? () => onAddToAlbum(sortedSharedAlbums[index])
: () {},
),
),
],
),
);
}
// Build albums list
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
final album = albums[offset];
final album = sortedAlbums[offset];
return AlbumThumbnailListTile(
album: album,
onTap: enabled ? () => onAddToAlbum(album) : () {},

View File

@@ -110,6 +110,7 @@ class AlbumThumbnailCard extends StatelessWidget {
width: cardSize,
child: Text(
album.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w500,

View File

@@ -68,46 +68,46 @@ class AlbumThumbnailListTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
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(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
album.assetCount == 1
? 'album_thumbnail_card_item'
: 'album_thumbnail_card_items',
style: const TextStyle(
fontSize: 12,
),
).tr(),
],
),
],
).tr(args: ['${album.assetCount}']),
if (album.shared)
const Text(
'album_thumbnail_card_shared',
style: TextStyle(
fontSize: 12,
),
).tr(),
],
),
],
),
),
),
],

View File

@@ -5,14 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@@ -22,8 +18,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
Key? key,
required this.album,
required this.userId,
required this.selected,
required this.selectionDisabled,
required this.titleFocusNode,
this.onAddPhotos,
this.onAddUsers,
@@ -32,8 +26,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
final Album album;
final String userId;
final Set<Asset> selected;
final void Function() selectionDisabled;
final FocusNode titleFocusNode;
final Function(Album album)? onAddPhotos;
final Function(Album album)? onAddUsers;
@@ -144,109 +136,27 @@ class AlbumViewerAppbar extends HookConsumerWidget
isProcessing.value = false;
}
void onRemoveFromAlbumPressed() async {
isProcessing.value = true;
bool isSuccess =
await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
album,
selected,
);
if (isSuccess) {
context.pop();
selectionDisabled();
ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(albumDetailProvider(album.id));
} else {
context.pop();
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_remove".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
isProcessing.value = false;
}
void handleShareAssets(
WidgetRef ref,
BuildContext context,
Set<Asset> selection,
) {
showDialog(
context: context,
builder: (BuildContext buildContext) {
ref.watch(shareServiceProvider).shareAssets(selection.toList()).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
buildContext.pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,
);
}
void onShareAssetsTo() async {
isProcessing.value = true;
handleShareAssets(ref, context, selected);
isProcessing.value = false;
}
buildBottomSheetActions() {
if (selected.isNotEmpty) {
return [
ListTile(
leading: const Icon(Icons.ios_share_rounded),
title: const Text(
'album_viewer_appbar_share_to',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onShareAssetsTo(),
),
album.ownerId == userId
? ListTile(
leading: const Icon(Icons.delete_sweep_rounded),
title: const Text(
'album_viewer_appbar_share_remove',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onRemoveFromAlbumPressed(),
)
: const SizedBox(),
];
} else {
return [
album.ownerId == userId
? ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text(
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onDeleteAlbumPressed(),
)
: ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text(
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onLeaveAlbumPressed(),
),
];
}
return [
album.ownerId == userId
? ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text(
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onDeleteAlbumPressed(),
)
: ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text(
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onLeaveAlbumPressed(),
),
];
// }
}
void buildBottomSheet() {
@@ -308,10 +218,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
mainAxisSize: MainAxisSize.min,
children: [
...buildBottomSheetActions(),
if (selected.isEmpty && onAddPhotos != null) ...commonActions,
if (selected.isEmpty &&
onAddPhotos != null &&
userId == album.ownerId)
if (onAddPhotos != null) ...commonActions,
if (onAddPhotos != null && userId == album.ownerId)
...ownerActions,
],
),
@@ -349,13 +257,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
}
buildLeadingButton() {
if (selected.isNotEmpty) {
return IconButton(
onPressed: selectionDisabled,
icon: const Icon(Icons.close_rounded),
splashRadius: 25,
);
} else if (isEditAlbum) {
if (isEditAlbum) {
return IconButton(
onPressed: () async {
bool isSuccess = await ref
@@ -388,7 +290,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
return AppBar(
elevation: 0,
leading: buildLeadingButton(),
title: selected.isNotEmpty ? Text('${selected.length}') : null,
centerTitle: false,
actions: [
if (album.shared && (album.activityEnabled || comments != 0))

View File

@@ -1,23 +1,26 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/current_album.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/modules/album/ui/album_action_outlined_button.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@@ -29,39 +32,30 @@ class AlbumViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
final album = ref.watch(albumDetailProvider(albumId));
final album = ref.watch(albumWatcher(albumId));
album.whenData(
(value) =>
Future((() => ref.read(currentAlbumProvider.notifier).state = value)),
);
final userId = ref.watch(authenticationProvider).userId;
final selection = useState<Set<Asset>>({});
final multiSelectEnabled = useState(false);
final isProcessing = useProcessingOverlay();
useEffect(
() {
// Fetch album updates, e.g., cover image
ref.invalidate(albumDetailProvider(albumId));
return null;
},
[],
);
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
final a = album.valueOrNull;
final bool isSuccess = a != null &&
await ref
.read(sharedAlbumProvider.notifier)
.removeAssetFromAlbum(a, assets);
Future<bool> onWillPop() async {
if (multiSelectEnabled.value) {
selection.value = {};
multiSelectEnabled.value = false;
return false;
if (!isSuccess) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_remove".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
return true;
}
void selectionListener(bool active, Set<Asset> selected) {
selection.value = selected;
multiSelectEnabled.value = selected.isNotEmpty;
}
void disableSelection() {
selection.value = {};
multiSelectEnabled.value = false;
return isSuccess;
}
/// Find out if the assets in album exist on the device
@@ -80,15 +74,10 @@ class AlbumViewerPage extends HookConsumerWidget {
// Check if there is new assets add
isProcessing.value = true;
var addAssetsResult =
await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
returnPayload.selectedAssets,
albumInfo,
);
if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) {
ref.invalidate(albumDetailProvider(albumId));
}
await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
returnPayload.selectedAssets,
albumInfo,
);
isProcessing.value = false;
}
@@ -102,14 +91,10 @@ class AlbumViewerPage extends HookConsumerWidget {
if (sharedUserIds != null) {
isProcessing.value = true;
var isSuccess = await ref
await ref
.watch(albumServiceProvider)
.addAdditionalUserToAlbum(sharedUserIds, album);
if (isSuccess) {
ref.invalidate(albumDetailProvider(album.id));
}
isProcessing.value = false;
}
}
@@ -193,10 +178,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildSharedUserIconsRow(Album album) {
return GestureDetector(
onTap: () async {
await context.autoPush(AlbumOptionsRoute(album: album));
ref.invalidate(albumDetailProvider(album.id));
},
onTap: () => context.autoPush(AlbumOptionsRoute(album: album)),
child: SizedBox(
height: 50,
child: ListView.builder(
@@ -244,42 +226,32 @@ class AlbumViewerPage extends HookConsumerWidget {
}
return Scaffold(
appBar: album.when(
data: (data) => AlbumViewerAppbar(
titleFocusNode: titleFocusNode,
album: data,
userId: userId,
selected: selection.value,
selectionDisabled: disableSelection,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed,
),
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
),
body: album.widgetWhen(
onData: (data) => WillPopScope(
onWillPop: onWillPop,
child: GestureDetector(
onTap: () => titleFocusNode.unfocus(),
child: ImmichAssetGrid(
renderList: data.renderList,
listener: selectionListener,
selectionActive: multiSelectEnabled.value,
showMultiSelectIndicator: false,
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
],
appBar: ref.watch(multiselectProvider)
? null
: album.when(
data: (data) => AlbumViewerAppbar(
titleFocusNode: titleFocusNode,
album: data,
userId: userId,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed,
),
isOwner: userId == data.ownerId,
sharedAlbumId:
data.shared && data.activityEnabled ? data.remoteId : null,
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
),
body: album.widgetWhen(
onData: (data) => MultiselectGrid(
renderListProvider: albumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
editEnabled: data.ownerId == userId,
),
),
);

View File

@@ -1,15 +1,12 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.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';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
@@ -21,8 +18,9 @@ class LibraryPage extends HookConsumerWidget {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider);
var isDarkTheme = context.isDarkTheme;
var settings = ref.watch(appSettingsServiceProvider);
final isDarkTheme = context.isDarkTheme;
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
useEffect(
() {
@@ -32,64 +30,15 @@ class LibraryPage extends HookConsumerWidget {
[],
);
final selectedAlbumSortOrder =
useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
List<Album> sortedAlbums() {
// Created.
if (selectedAlbumSortOrder.value == 0) {
return albums
.where((a) => a.isRemote)
.sortedBy((album) => album.createdAt)
.reversed
.toList();
}
// Album title.
if (selectedAlbumSortOrder.value == 1) {
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
}
// Most recent photo, if unset (e.g. empty album, use modifiedAt / updatedAt).
if (selectedAlbumSortOrder.value == 2) {
return albums
.where((a) => a.isRemote)
.sorted(
(a, b) => a.lastModifiedAssetTimestamp != null &&
b.lastModifiedAssetTimestamp != null
? a.lastModifiedAssetTimestamp!
.compareTo(b.lastModifiedAssetTimestamp!)
: a.modifiedAt.compareTo(b.modifiedAt),
)
.reversed
.toList();
}
// Last modified.
if (selectedAlbumSortOrder.value == 3) {
return albums
.where((a) => a.isRemote)
.sortedBy((album) => album.modifiedAt)
.reversed
.toList();
}
// Fallback: Album title.
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
}
Widget buildSortButton() {
final options = [
"library_page_sort_created".tr(),
"library_page_sort_title".tr(),
"library_page_sort_most_recent_photo".tr(),
"library_page_sort_last_modified".tr(),
];
return PopupMenuButton(
position: PopupMenuPosition.over,
itemBuilder: (BuildContext context) {
return options.mapIndexed<PopupMenuEntry<int>>((index, option) {
final selected = selectedAlbumSortOrder.value == index;
return AlbumSortMode.values
.map<PopupMenuEntry<AlbumSortMode>>((option) {
final selected = albumSortOption == option;
return PopupMenuItem(
value: index,
value: option,
child: Row(
children: [
Padding(
@@ -101,10 +50,10 @@ class LibraryPage extends HookConsumerWidget {
),
),
Text(
option,
option.label.tr(),
style: TextStyle(
color: selected ? context.primaryColor : null,
fontSize: 12.0,
fontSize: 14.0,
),
),
],
@@ -112,19 +61,31 @@ class LibraryPage extends HookConsumerWidget {
);
}).toList();
},
onSelected: (int value) {
selectedAlbumSortOrder.value = value;
settings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, value);
onSelected: (AlbumSortMode value) {
final selected = albumSortOption == value;
// Switch direction
if (selected) {
ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
} else {
ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value);
}
},
child: Row(
children: [
Icon(
Icons.swap_vert_rounded,
size: 18,
color: context.primaryColor,
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(
albumSortIsReverse
? Icons.arrow_downward_rounded
: Icons.arrow_upward_rounded,
size: 14,
color: context.primaryColor,
),
),
Text(
options[selectedAlbumSortOrder.value],
albumSortOption.label.tr(),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
@@ -135,49 +96,55 @@ class LibraryPage extends HookConsumerWidget {
}
Widget buildCreateAlbumButton() {
return GestureDetector(
onTap: () {
context.autoPush(CreateAlbumRoute(isSharedAlbum: false));
return LayoutBuilder(
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
return GestureDetector(
onTap: () =>
context.autoPush(CreateAlbumRoute(isSharedAlbum: false)),
child: Padding(
padding:
const EdgeInsets.only(bottom: 32), // Adjust padding to suit
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: cardSize,
height: cardSize,
decoration: BoxDecoration(
border: Border.all(
color: isDarkTheme
? const Color.fromARGB(255, 53, 53, 53)
: const Color.fromARGB(255, 203, 203, 203),
),
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Center(
child: Icon(
Icons.add_rounded,
size: 28,
color: context.primaryColor,
),
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 16,
),
child: Text(
'library_page_new_album',
style: context.textTheme.labelLarge,
).tr(),
),
],
),
),
);
},
child: Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: isDarkTheme
? const Color.fromARGB(255, 53, 53, 53)
: const Color.fromARGB(255, 203, 203, 203),
),
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Icon(
Icons.add_rounded,
size: 28,
color: context.primaryColor,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 16,
),
child: Text(
'library_page_new_album',
style: context.textTheme.labelLarge,
).tr(),
),
],
),
),
);
}
@@ -216,15 +183,15 @@ class LibraryPage extends HookConsumerWidget {
);
}
final sorted = sortedAlbums();
final remote = albums.where((a) => a.isRemote).toList();
final sorted = albumSortOption.sortFn(remote, albumSortIsReverse);
final local = albums.where((a) => a.isLocal).toList();
Widget? shareTrashButton() {
return trashEnabled
? InkWell(
onTap: () => context.autoPush(const TrashRoute()),
borderRadius: BorderRadius.circular(12),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: const Icon(
Icons.delete_rounded,
size: 25,

View File

@@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.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/partner/providers/partner.provider.dart';
import 'package:immich_mobile/modules/partner/ui/partner_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
@@ -18,7 +18,10 @@ class SharingPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
final albums = ref.watch(sharedAlbumProvider);
final sharedAlbums = albumSortOption.sortFn(albums, albumSortIsReverse);
final userId = ref.watch(currentUserProvider)?.id;
final partner = ref.watch(partnerSharedWithProvider);
@@ -68,7 +71,7 @@ class SharingPage extends HookConsumerWidget {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ImmichImage(
album.thumbnail.value,
width: 60,
@@ -167,9 +170,9 @@ class SharingPage extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: const BorderSide(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
side: BorderSide(
color: Colors.grey,
width: 0.5,
),
@@ -212,7 +215,7 @@ class SharingPage extends HookConsumerWidget {
Widget sharePartnerButton() {
return InkWell(
onTap: () => context.autoPush(const PartnerRoute()),
borderRadius: BorderRadius.circular(12),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: const Icon(
Icons.swap_horizontal_circle_rounded,
size: 25,

View File

@@ -1,34 +1,19 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
class ArchivePage extends HookConsumerWidget {
const ArchivePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final archivedAssets = ref.watch(archiveProvider);
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
final processing = useState(false);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
AppBar buildAppBar(String count) {
AppBar buildAppBar() {
final archivedAssets = ref.watch(archiveProvider);
final count = archivedAssets.value?.totalAssets.toString() ?? "?";
return AppBar(
leading: IconButton(
onPressed: () => context.autoPop(),
@@ -42,69 +27,14 @@ class ArchivePage extends HookConsumerWidget {
);
}
Widget buildBottomBar() {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Card(
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
leading: const Icon(
Icons.unarchive_rounded,
),
title: Text(
'control_bottom_app_bar_unarchive'.tr(),
style: const TextStyle(fontSize: 14),
),
onTap: processing.value
? null
: () async {
processing.value = true;
try {
await handleArchiveAssets(
ref,
context,
selection.value.toList(),
shouldArchive: false,
);
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
},
),
),
),
),
);
}
return Scaffold(
appBar: archivedAssets.maybeWhen(
data: (data) => buildAppBar(data.totalAssets.toString()),
orElse: () => buildAppBar("?"),
),
body: archivedAssets.widgetWhen(
onData: (data) => data.isEmpty
? Center(
child: Text('archive_page_no_archived_assets'.tr()),
)
: Stack(
children: [
ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
if (selectionEnabledHook.value) buildBottomBar(),
if (processing.value)
const Center(child: ImmichLoadingIndicator()),
],
),
appBar: ref.watch(multiselectProvider) ? null : buildAppBar(),
body: MultiselectGrid(
renderListProvider: archiveProvider,
unarchive: true,
archiveEnabled: true,
deleteEnabled: true,
editEnabled: true,
),
);
}

View File

@@ -4,14 +4,15 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asset_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:timezone/timezone.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -21,111 +22,84 @@ class ExifBottomSheet extends HookConsumerWidget {
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
bool hasCoordinates(ExifInfo? exifInfo) =>
exifInfo != null &&
exifInfo.latitude != null &&
exifInfo.longitude != null &&
exifInfo.latitude != 0 &&
exifInfo.longitude != 0;
String formatTimeZone(Duration d) =>
"GMT${d.isNegative ? '-' : '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
String get formattedDateTime {
DateTime dt = asset.fileCreatedAt.toLocal();
String? timeZone;
if (asset.exifInfo?.dateTimeOriginal != null) {
dt = asset.exifInfo!.dateTimeOriginal!;
if (asset.exifInfo?.timeZone != null) {
dt = dt.toUtc();
try {
final location = getLocation(asset.exifInfo!.timeZone!);
dt = TZDateTime.from(dt, location);
} on LocationNotFoundException {
RegExp re = RegExp(
r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
caseSensitive: false,
);
final m = re.firstMatch(asset.exifInfo!.timeZone!);
if (m != null) {
final duration = Duration(
hours: int.parse(m.group(1) ?? '0'),
minutes: int.parse(m.group(2) ?? '0'),
);
dt = dt.add(duration);
timeZone = formatTimeZone(duration);
}
}
}
}
final date = DateFormat.yMMMEd().format(dt);
final time = DateFormat.jm().format(dt);
timeZone ??= formatTimeZone(dt.timeZoneOffset);
return '$date$time $timeZone';
}
Future<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {
if (!hasCoordinates(exifInfo)) {
return null;
}
final double latitude = exifInfo!.latitude!;
final double longitude = exifInfo.longitude!;
const zoomLevel = 16;
if (Platform.isAndroid) {
Uri uri = Uri(
scheme: 'geo',
host: '$latitude,$longitude',
queryParameters: {
'z': '$zoomLevel',
'q': '$latitude,$longitude($formattedDateTime)',
},
);
if (await canLaunchUrl(uri)) {
return uri;
}
} else if (Platform.isIOS) {
var params = {
'll': '$latitude,$longitude',
'q': formattedDateTime,
'z': '$zoomLevel',
};
Uri uri = Uri.https('maps.apple.com', '/', params);
if (await canLaunchUrl(uri)) {
return uri;
}
}
return Uri(
scheme: 'https',
host: 'openstreetmap.org',
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
fragment: 'map=$zoomLevel/$latitude/$longitude',
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetWithExif = ref.watch(assetDetailProvider(asset));
final exifInfo = (assetWithExif.value ?? asset).exifInfo;
var textColor = context.isDarkTheme ? Colors.white : Colors.black;
bool hasCoordinates() =>
exifInfo != null &&
exifInfo.latitude != null &&
exifInfo.longitude != null &&
exifInfo.latitude != 0 &&
exifInfo.longitude != 0;
String formattedDateTime() {
final (dt, timeZone) =
(assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset();
final date = DateFormat.yMMMEd().format(dt);
final time = DateFormat.jm().format(dt);
return '$date$time GMT${timeZone.formatAsOffset()}';
}
Future<Uri?> createCoordinatesUri() async {
if (!hasCoordinates()) {
return null;
}
final double latitude = exifInfo!.latitude!;
final double longitude = exifInfo.longitude!;
const zoomLevel = 16;
if (Platform.isAndroid) {
Uri uri = Uri(
scheme: 'geo',
host: '$latitude,$longitude',
queryParameters: {
'z': '$zoomLevel',
'q': '$latitude,$longitude($formattedDateTime)',
},
);
if (await canLaunchUrl(uri)) {
return uri;
}
} else if (Platform.isIOS) {
var params = {
'll': '$latitude,$longitude',
'q': formattedDateTime,
'z': '$zoomLevel',
};
Uri uri = Uri.https('maps.apple.com', '/', params);
if (await canLaunchUrl(uri)) {
return uri;
}
}
return Uri(
scheme: 'https',
host: 'openstreetmap.org',
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
fragment: 'map=$zoomLevel/$latitude/$longitude',
);
}
buildMap() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: LayoutBuilder(
builder: (context, constraints) {
return MapThumbnail(
showAttribution: false,
coords: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
height: 150,
zoom: 16.0,
width: constraints.maxWidth,
zoom: 12.0,
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
@@ -139,7 +113,7 @@ class ExifBottomSheet extends HookConsumerWidget {
),
],
onTap: (tapPosition, latLong) async {
Uri? uri = await _createCoordinatesUri(exifInfo);
Uri? uri = await createCoordinatesUri();
if (uri == null) {
return;
@@ -181,8 +155,26 @@ class ExifBottomSheet extends HookConsumerWidget {
buildLocation() {
// Guard no lat/lng
if (!hasCoordinates(exifInfo)) {
return Container();
if (!hasCoordinates()) {
return asset.isRemote
? ListTile(
minLeadingWidth: 0,
contentPadding: const EdgeInsets.all(0),
leading: const Icon(Icons.location_on),
title: Text(
"exif_bottom_sheet_location_add",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
onTap: () => handleEditLocation(
ref,
context,
[assetWithExif.value ?? asset],
),
)
: const SizedBox.shrink();
}
return Column(
@@ -191,13 +183,29 @@ class ExifBottomSheet extends HookConsumerWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"exif_bottom_sheet_location",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"exif_bottom_sheet_location",
style: context.textTheme.labelMedium?.copyWith(
color:
context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
if (asset.isRemote)
IconButton(
onPressed: () => handleEditLocation(
ref,
context,
[assetWithExif.value ?? asset],
),
icon: const Icon(Icons.edit_outlined),
iconSize: 20,
),
],
),
buildMap(),
RichText(
text: TextSpan(
@@ -233,12 +241,27 @@ class ExifBottomSheet extends HookConsumerWidget {
}
buildDate() {
return Text(
formattedDateTime,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
formattedDateTime(),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
if (asset.isRemote)
IconButton(
onPressed: () => handleEditDateTime(
ref,
context,
[assetWithExif.value ?? asset],
),
icon: const Icon(Icons.edit_outlined),
iconSize: 20,
),
],
);
}
@@ -363,7 +386,7 @@ class ExifBottomSheet extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: hasCoordinates(exifInfo) ? 5 : 0,
flex: hasCoordinates() ? 5 : 0,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: buildLocation(),
@@ -402,9 +425,8 @@ class ExifBottomSheet extends HookConsumerWidget {
child: CircularProgressIndicator.adaptive(),
),
),
const SizedBox(height: 8.0),
buildLocation(),
SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),
SizedBox(height: hasCoordinates() ? 16.0 : 6.0),
buildDetail(),
const SizedBox(height: 50),
],

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
@@ -17,8 +18,8 @@ class TopControlAppBar extends HookConsumerWidget {
required this.onFavorite,
required this.onUploadPressed,
required this.isOwner,
required this.shareAlbumId,
required this.onActivitiesPressed,
required this.isPartner,
}) : super(key: key);
final Asset asset;
@@ -31,16 +32,17 @@ class TopControlAppBar extends HookConsumerWidget {
final Function(Asset) onFavorite;
final bool isPlayingMotionVideo;
final bool isOwner;
final String? shareAlbumId;
final bool isPartner;
@override
Widget build(BuildContext context, WidgetRef ref) {
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final comments = shareAlbumId != null
final album = ref.watch(currentAlbumProvider);
final comments = album != null && album.remoteId != null
? ref.watch(
activityStatisticsStateProvider(
(albumId: shareAlbumId!, assetId: asset.remoteId),
(albumId: album.remoteId!, assetId: asset.remoteId),
),
)
: 0;
@@ -169,8 +171,8 @@ class TopControlAppBar extends HookConsumerWidget {
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && isOwner) buildAddToAlbumButtom(),
if (shareAlbumId != null) buildActivitiesButton(),
if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(),
if (album != null && album.shared) buildActivitiesButton(),
buildMoreInfoButton(),
],
);

View File

@@ -9,6 +9,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
@@ -22,6 +23,7 @@ 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/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/shared/cache/original_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
@@ -29,6 +31,7 @@ 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/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
@@ -49,8 +52,6 @@ class GalleryViewerPage extends HookConsumerWidget {
final int initialIndex;
final int heroOffset;
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
GalleryViewerPage({
super.key,
@@ -59,8 +60,6 @@ class GalleryViewerPage extends HookConsumerWidget {
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
}) : controller = PageController(initialPage: initialIndex);
final PageController controller;
@@ -94,10 +93,16 @@ class GalleryViewerPage extends HookConsumerWidget {
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromDto = currentAsset.id == Isar.autoIncrement;
final album = ref.watch(currentAlbumProvider);
Asset asset() => stackIndex.value == -1
? currentAsset
: stackElements.elementAt(stackIndex.value);
final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId;
final isPartner = ref
.watch(partnerSharedWithProvider)
.map((e) => e.isarId)
.contains(asset().ownerId);
bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
@@ -113,9 +118,8 @@ class GalleryViewerPage extends HookConsumerWidget {
[],
);
void toggleFavorite(Asset asset) => ref
.watch(assetProvider.notifier)
.toggleFavorite([asset], !asset.isFavorite);
void toggleFavorite(Asset asset) =>
ref.read(assetProvider.notifier).toggleFavorite([asset]);
/// Original (large) image of a remote asset. Required asset.isRemote
ImageProvider remoteOriginalProvider(Asset asset) =>
@@ -305,9 +309,7 @@ class GalleryViewerPage extends HookConsumerWidget {
}
handleArchive(Asset asset) {
ref
.watch(assetProvider.notifier)
.toggleArchive([asset], !asset.isArchived);
ref.watch(assetProvider.notifier).toggleArchive([asset]);
if (isParent) {
context.autoPop();
return;
@@ -331,10 +333,10 @@ class GalleryViewerPage extends HookConsumerWidget {
}
handleActivities() {
if (sharedAlbumId != null) {
if (album != null && album.shared && album.remoteId != null) {
context.autoPush(
ActivitiesRoute(
albumId: sharedAlbumId!,
albumId: album.remoteId!,
assetId: asset().remoteId,
withAssetThumbs: false,
isOwner: isOwner,
@@ -353,6 +355,7 @@ class GalleryViewerPage extends HookConsumerWidget {
color: Colors.black.withOpacity(0.4),
child: TopControlAppBar(
isOwner: isOwner,
isPartner: isPartner,
isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: asset(),
onMoreInfoPressed: showInfo,
@@ -371,7 +374,6 @@ class GalleryViewerPage extends HookConsumerWidget {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}),
onAddToAlbumPressed: () => addToAlbum(asset()),
shareAlbumId: sharedAlbumId,
onActivitiesPressed: handleActivities,
),
),
@@ -795,6 +797,7 @@ class GalleryViewerPage extends HookConsumerWidget {
tag: isFromDto
? '${a.remoteId}-$heroOffset'
: a.id + heroOffset,
transitionOnUserGestures: true,
),
filterQuality: FilterQuality.high,
tightMode: true,

View File

@@ -342,7 +342,8 @@ class BackgroundService {
ApiService apiService = ApiService();
apiService.setAccessToken(Store.get(StoreKey.accessToken));
BackupService backupService = BackupService(apiService, db);
AppSettingsService settingService = AppSettingsService();
BackupService backupService = BackupService(apiService, db, settingService);
AppSettingsService settingsService = AppSettingsService();
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
@@ -452,9 +453,12 @@ class BackgroundService {
);
_cancellationToken = CancellationToken();
final pmProgressHandler = PMProgressHandler();
final bool ok = await backupService.backupAsset(
toUpload,
_cancellationToken!,
pmProgressHandler,
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
notifySingleProgress ? _onProgress : (sent, total) {},
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},

View File

@@ -1,10 +1,12 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart';
enum BackUpProgressEnum {
idle,
@@ -19,6 +21,7 @@ class BackUpState {
final BackUpProgressEnum backupProgress;
final List<String> allAssetsInDatabase;
final double progressInPercentage;
final double iCloudDownloadProgress;
final CancellationToken cancelToken;
final ServerDiskInfo serverInfo;
final bool autoBackup;
@@ -45,6 +48,7 @@ class BackUpState {
required this.backupProgress,
required this.allAssetsInDatabase,
required this.progressInPercentage,
required this.iCloudDownloadProgress,
required this.cancelToken,
required this.serverInfo,
required this.autoBackup,
@@ -64,6 +68,7 @@ class BackUpState {
BackUpProgressEnum? backupProgress,
List<String>? allAssetsInDatabase,
double? progressInPercentage,
double? iCloudDownloadProgress,
CancellationToken? cancelToken,
ServerDiskInfo? serverInfo,
bool? autoBackup,
@@ -82,6 +87,8 @@ class BackUpState {
backupProgress: backupProgress ?? this.backupProgress,
allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase,
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
iCloudDownloadProgress:
iCloudDownloadProgress ?? this.iCloudDownloadProgress,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
autoBackup: autoBackup ?? this.autoBackup,
@@ -102,18 +109,18 @@ class BackUpState {
@override
String toString() {
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)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, 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
bool operator ==(Object other) {
bool operator ==(covariant BackUpState other) {
if (identical(this, other)) return true;
final collectionEquals = const DeepCollectionEquality().equals;
return other is BackUpState &&
other.backupProgress == backupProgress &&
return other.backupProgress == backupProgress &&
collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
other.progressInPercentage == progressInPercentage &&
other.iCloudDownloadProgress == iCloudDownloadProgress &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
other.autoBackup == autoBackup &&
@@ -137,6 +144,7 @@ class BackUpState {
return backupProgress.hashCode ^
allAssetsInDatabase.hashCode ^
progressInPercentage.hashCode ^
iCloudDownloadProgress.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
autoBackup.hashCode ^

View File

@@ -1,3 +1,4 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
class CurrentUploadAsset {
@@ -5,12 +6,14 @@ class CurrentUploadAsset {
final DateTime fileCreatedAt;
final String fileName;
final String fileType;
final bool? iCloudAsset;
CurrentUploadAsset({
required this.id,
required this.fileCreatedAt,
required this.fileName,
required this.fileType,
this.iCloudAsset,
});
CurrentUploadAsset copyWith({
@@ -18,54 +21,58 @@ class CurrentUploadAsset {
DateTime? fileCreatedAt,
String? fileName,
String? fileType,
bool? iCloudAsset,
}) {
return CurrentUploadAsset(
id: id ?? this.id,
fileCreatedAt: fileCreatedAt ?? this.fileCreatedAt,
fileName: fileName ?? this.fileName,
fileType: fileType ?? this.fileType,
iCloudAsset: iCloudAsset ?? this.iCloudAsset,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'id': id});
result.addAll({'fileCreatedAt': fileCreatedAt.millisecondsSinceEpoch});
result.addAll({'fileName': fileName});
result.addAll({'fileType': fileType});
return result;
return <String, dynamic>{
'id': id,
'fileCreatedAt': fileCreatedAt.millisecondsSinceEpoch,
'fileName': fileName,
'fileType': fileType,
'iCloudAsset': iCloudAsset,
};
}
factory CurrentUploadAsset.fromMap(Map<String, dynamic> map) {
return CurrentUploadAsset(
id: map['id'] ?? '',
fileCreatedAt: DateTime.fromMillisecondsSinceEpoch(map['fileCreatedAt']),
fileName: map['fileName'] ?? '',
fileType: map['fileType'] ?? '',
id: map['id'] as String,
fileCreatedAt:
DateTime.fromMillisecondsSinceEpoch(map['fileCreatedAt'] as int),
fileName: map['fileName'] as String,
fileType: map['fileType'] as String,
iCloudAsset:
map['iCloudAsset'] != null ? map['iCloudAsset'] as bool : null,
);
}
String toJson() => json.encode(toMap());
factory CurrentUploadAsset.fromJson(String source) =>
CurrentUploadAsset.fromMap(json.decode(source));
CurrentUploadAsset.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'CurrentUploadAsset(id: $id, fileCreatedAt: $fileCreatedAt, fileName: $fileName, fileType: $fileType)';
return 'CurrentUploadAsset(id: $id, fileCreatedAt: $fileCreatedAt, fileName: $fileName, fileType: $fileType, iCloudAsset: $iCloudAsset)';
}
@override
bool operator ==(Object other) {
bool operator ==(covariant CurrentUploadAsset other) {
if (identical(this, other)) return true;
return other is CurrentUploadAsset &&
other.id == id &&
return other.id == id &&
other.fileCreatedAt == fileCreatedAt &&
other.fileName == fileName &&
other.fileType == fileType;
other.fileType == fileType &&
other.iCloudAsset == iCloudAsset;
}
@override
@@ -73,6 +80,7 @@ class CurrentUploadAsset {
return id.hashCode ^
fileCreatedAt.hashCode ^
fileName.hashCode ^
fileType.hashCode;
fileType.hashCode ^
iCloudAsset.hashCode;
}
}

View File

@@ -61,7 +61,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
iCloudAsset: false,
),
iCloudDownloadProgress: 0.0,
),
);
@@ -375,6 +377,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await _getBackupAlbumsInfo();
await updateServerInfo();
await _updateBackupAssetCount();
} else {
log.warning("cannot get backup info - background backup is in progress!");
}
}
@@ -442,9 +446,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
final pmProgressHandler = PMProgressHandler();
pmProgressHandler.stream.listen((event) {
final double progress = event.progress;
state = state.copyWith(iCloudDownloadProgress: progress);
});
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,

View File

@@ -208,10 +208,12 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
state.totalAssetsToUpload == 1;
state =
state.copyWith(showDetailedNotification: showDetailedNotification);
final pmProgressHandler = PMProgressHandler();
final bool ok = await ref.read(backupServiceProvider).backupAsset(
allUploadAssets,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onProgress,
_onSetCurrentBackupAsset,

View File

@@ -10,6 +10,8 @@ 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/modules/settings/providers/app_settings.provider.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/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
@@ -26,6 +28,7 @@ final backupServiceProvider = Provider(
(ref) => BackupService(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref.watch(appSettingsServiceProvider),
),
);
@@ -34,8 +37,9 @@ class BackupService {
final ApiService _apiService;
final Isar _db;
final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting;
BackupService(this._apiService, this._db);
BackupService(this._apiService, this._db, this._appSetting);
Future<List<String>?> getDeviceBackupAsset() async {
final String deviceId = Store.get(StoreKey.deviceId);
@@ -202,12 +206,16 @@ class BackupService {
Future<bool> backupAsset(
Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken,
PMProgressHandler pmProgressHandler,
Function(String, String, bool) uploadSuccessCb,
Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb, {
bool sortAssets = false,
}) async {
final bool isIgnoreIcloudAssets =
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
if (Platform.isAndroid &&
!(await Permission.accessMediaLocation.status).isGranted) {
// double check that permission is granted here, to guard against
@@ -241,10 +249,34 @@ class BackupService {
for (var entity in assetsToUpload) {
try {
if (entity.type == AssetType.video) {
file = await entity.originFile;
final isAvailableLocally = await entity.isLocallyAvailable();
// Handle getting files from iCloud
if (!isAvailableLocally && Platform.isIOS) {
// Skip iCloud assets if the user has disabled this feature
if (isIgnoreIcloudAssets) {
continue;
}
setCurrentUploadAssetCb(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
? entity.modifiedDateTime
: entity.createDateTime,
fileName: await entity.titleAsync,
fileType: _getAssetType(entity.type),
iCloudAsset: true,
),
);
file = await entity.loadFile(progressHandler: pmProgressHandler);
} else {
file = await entity.originFile.timeout(const Duration(seconds: 5));
if (entity.type == AssetType.video) {
file = await entity.originFile;
} else {
file = await entity.originFile.timeout(const Duration(seconds: 5));
}
}
if (file != null) {
@@ -286,6 +318,7 @@ class BackupService {
: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
iCloudAsset: false,
),
);

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -23,6 +25,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
var uploadProgress = !isManualUpload
? ref.watch(backupProvider).progressInPercentage
: ref.watch(manualUploadProvider).progressInPercentage;
var iCloudDownloadProgress =
ref.watch(backupProvider).iCloudDownloadProgress;
final isShowThumbnail = useState(false);
String getAssetCreationDate() {
@@ -143,6 +147,69 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
}
}
buildiCloudDownloadProgerssBar() {
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
"iCloud Download",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
backgroundColor: Colors.grey,
color: context.primaryColor,
),
),
Text(
" ${iCloudDownloadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
],
),
);
}
return const SizedBox();
}
buildUploadProgressBar() {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
if (asset.iCloudAsset != null && asset.iCloudAsset!)
SizedBox(
width: 110,
child: Text(
"Immich Upload",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
backgroundColor: Colors.grey,
color: context.primaryColor,
),
),
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
],
),
);
}
return FutureBuilder<Uint8List?>(
future: buildAssetThumbnail(),
builder: (context, thumbnail) => ListTile(
@@ -197,25 +264,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
),
subtitle: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
backgroundColor: Colors.grey,
color: context.primaryColor,
),
),
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
],
),
),
if (Platform.isIOS) buildiCloudDownloadProgerssBar(),
buildUploadProgressBar(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: buildAssetInfoTable(),

View File

@@ -1,33 +1,21 @@
import 'dart:io';
import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/backup/services/backup_verification.service.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/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class BackupControllerPage extends HookConsumerWidget {
const BackupControllerPage({Key? key}) : super(key: key);
@@ -35,14 +23,8 @@ class BackupControllerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
final settingsService = ref.watch(appSettingsServiceProvider);
final showBackupFix = Platform.isAndroid &&
settingsService.getSetting(AppSettingsEnum.advancedTroubleshooting);
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
final appRefreshDisabled =
Platform.isIOS && settings?.appRefreshEnabled != true;
bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length -
@@ -51,7 +33,6 @@ class BackupControllerPage extends HookConsumerWidget {
!hasExclusiveAccess
? false
: true;
final checkInProgress = useState(false);
useEffect(
() {
@@ -75,426 +56,6 @@ class BackupControllerPage extends HookConsumerWidget {
[],
);
Future<void> performDeletion(List<Asset> assets) async {
try {
checkInProgress.value = true;
ImmichToast.show(
context: context,
msg: "Deleting ${assets.length} assets on the server...",
);
await ref
.read(assetProvider.notifier)
.deleteAssets(assets, force: true);
ImmichToast.show(
context: context,
msg: "Deleted ${assets.length} assets on the server. "
"You can now start a manual backup",
toastType: ToastType.success,
);
} finally {
checkInProgress.value = false;
}
}
void performBackupCheck() async {
try {
checkInProgress.value = true;
if (backupState.allUniqueAssets.length >
backupState.selectedAlbumsBackupAssetsIds.length) {
ImmichToast.show(
context: context,
msg: "Backup all assets before starting this check!",
toastType: ToastType.error,
);
return;
}
final connection = await Connectivity().checkConnectivity();
if (connection != ConnectivityResult.wifi) {
ImmichToast.show(
context: context,
msg: "Make sure to be connected to unmetered Wi-Fi",
toastType: ToastType.error,
);
return;
}
WakelockPlus.enable();
const limit = 100;
final toDelete = await ref
.read(backupVerificationServiceProvider)
.findWronglyBackedUpAssets(limit: limit);
if (toDelete.isEmpty) {
ImmichToast.show(
context: context,
msg: "Did not find any corrupt asset backups!",
toastType: ToastType.success,
);
} else {
await showDialog(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => performDeletion(toDelete),
title: "Corrupt backups!",
ok: "Delete",
content:
"Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
"Run the check again to find more.\n"
"Do you want to delete the corrupt asset backups now?",
),
);
}
} finally {
WakelockPlus.disable();
checkInProgress.value = false;
}
}
Widget buildCheckCorruptBackups() {
return ListTile(
leading: Icon(
Icons.warning_rounded,
color: context.primaryColor,
),
title: const Text(
"Check for corrupt asset backups",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
isThreeLine: true,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Run this check only over Wi-Fi and once all assets "
"have been backed-up. The procedure might take a few minutes."),
ElevatedButton(
onPressed: checkInProgress.value ? null : performBackupCheck,
child: checkInProgress.value
? const CircularProgressIndicator()
: const Text("Perform check"),
),
],
),
);
}
ListTile buildAutoBackupController() {
final isAutoBackup = backupState.autoBackup;
final backUpOption = isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
final backupBtnText = isAutoBackup
? "backup_controller_page_turn_off".tr()
: "backup_controller_page_turn_on".tr();
return ListTile(
isThreeLine: true,
leading: isAutoBackup
? Icon(
Icons.cloud_done_rounded,
color: context.primaryColor,
)
: const Icon(Icons.cloud_off_rounded),
title: Text(
backUpOption,
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isAutoBackup)
const Text(
"backup_controller_page_desc_backup",
style: TextStyle(fontSize: 14),
).tr(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ElevatedButton(
onPressed: () => ref
.read(backupProvider.notifier)
.setAutoBackup(!isAutoBackup),
child: Text(
backupBtnText,
style: context.textTheme.labelLarge?.copyWith(
color: context.isDarkTheme ? Colors.black : Colors.white,
),
),
),
),
],
),
),
);
}
void showErrorToUser(String msg) {
final snackBar = SnackBar(
content: Text(
msg.tr(),
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
),
backgroundColor: Colors.red,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showBatteryOptimizationInfoToUser() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text(
'backup_controller_page_background_battery_info_title',
).tr(),
content: SingleChildScrollView(
child: const Text(
'backup_controller_page_background_battery_info_message',
).tr(),
),
actions: [
ElevatedButton(
onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication,
),
child: const Text(
"backup_controller_page_background_battery_info_link",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
),
ElevatedButton(
child: const Text(
'backup_controller_page_background_battery_info_ok',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
onPressed: () {
context.pop();
},
),
],
);
},
);
}
Widget buildBackgroundBackupController() {
final bool isBackgroundEnabled = backupState.backgroundBackup;
final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging;
final Color activeColor = context.primaryColor;
String formatBackupDelaySliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_seconds'.tr(args: const ['5']);
} else if (v == 1.0) {
return 'setting_notifications_notify_seconds'.tr(args: const ['30']);
} else if (v == 2.0) {
return 'setting_notifications_notify_minutes'.tr(args: const ['2']);
} else {
return 'setting_notifications_notify_minutes'.tr(args: const ['10']);
}
}
int backupDelayToMilliseconds(double v) {
if (v == 0.0) {
return 5000;
} else if (v == 1.0) {
return 30000;
} else if (v == 2.0) {
return 120000;
} else {
return 600000;
}
}
double backupDelayToSliderValue(int ms) {
if (ms == 5000) {
return 0.0;
} else if (ms == 30000) {
return 1.0;
} else if (ms == 120000) {
return 2.0;
} else {
return 3.0;
}
}
final triggerDelay =
useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
return Column(
children: [
ListTile(
isThreeLine: true,
leading: isBackgroundEnabled
? Icon(
Icons.cloud_sync_rounded,
color: activeColor,
)
: const Icon(Icons.cloud_sync_rounded),
title: Text(
isBackgroundEnabled
? "backup_controller_page_background_is_on"
: "backup_controller_page_background_is_off",
style: context.textTheme.titleSmall,
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isBackgroundEnabled)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: const Text(
"backup_controller_page_background_description",
).tr(),
),
if (isBackgroundEnabled && Platform.isAndroid)
SwitchListTile.adaptive(
title: const Text("backup_controller_page_background_wifi")
.tr(),
secondary: Icon(
Icons.wifi,
color: isWifiRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isWifiRequired,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
),
if (isBackgroundEnabled)
SwitchListTile.adaptive(
title:
const Text("backup_controller_page_background_charging")
.tr(),
secondary: Icon(
Icons.charging_station,
color: isChargingRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isChargingRequired,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
),
if (isBackgroundEnabled && Platform.isAndroid)
ListTile(
isThreeLine: false,
dense: true,
title: const Text(
'backup_controller_page_background_delay',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(
args: [formatBackupDelaySliderValue(triggerDelay.value)],
),
subtitle: Slider(
value: triggerDelay.value,
onChanged: (double v) => triggerDelay.value = v,
onChangeEnd: (double v) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
triggerDelay: backupDelayToMilliseconds(v),
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
max: 3.0,
divisions: 3,
label: formatBackupDelaySliderValue(triggerDelay.value),
activeColor: context.primaryColor,
),
),
ElevatedButton(
onPressed: () => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
enabled: !isBackgroundEnabled,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
child: Text(
isBackgroundEnabled
? "backup_controller_page_background_turn_off"
: "backup_controller_page_background_turn_on",
style: context.textTheme.labelLarge?.copyWith(
color: context.isDarkTheme ? Colors.black : Colors.white,
),
).tr(),
),
],
),
),
if (isBackgroundEnabled && Platform.isIOS)
FutureBuilder(
future: ref
.read(backgroundServiceProvider)
.getIOSBackgroundAppRefreshEnabled(),
builder: (context, snapshot) {
final enabled = snapshot.data;
// If it's not enabled, show them some kind of alert that says
// background refresh is not enabled
if (enabled != null && !enabled) {}
// If it's enabled, no need to bother them
return Container();
},
),
if (Platform.isIOS && isBackgroundEnabled && settings != null)
IosDebugInfoTile(
settings: settings,
),
],
);
}
Widget buildBackgroundAppRefreshWarning() {
return ListTile(
isThreeLine: true,
leading: const Icon(
Icons.task_outlined,
),
title: const Text(
'backup_controller_page_background_app_refresh_disabled_title',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: const Text(
'backup_controller_page_background_app_refresh_disabled_content',
).tr(),
),
ElevatedButton(
onPressed: () => openAppSettings(),
child: const Text(
'backup_controller_page_background_app_refresh_enable_button_text',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
).tr(),
),
],
),
);
}
Widget buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums;
@@ -688,6 +249,18 @@ class BackupControllerPage extends HookConsumerWidget {
Icons.arrow_back_ios_rounded,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
onPressed: () => context.autoPush(const BackupOptionsRoute()),
splashRadius: 24,
icon: const Icon(
Icons.settings_outlined,
),
),
),
],
),
body: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
@@ -715,22 +288,9 @@ class BackupControllerPage extends HookConsumerWidget {
subtitle: "backup_controller_page_remainder_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
),
const Divider(),
buildAutoBackupController(),
const Divider(),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Platform.isIOS
? (appRefreshDisabled
? buildBackgroundAppRefreshWarning()
: buildBackgroundBackupController())
: buildBackgroundBackupController(),
),
if (showBackupFix) const Divider(),
if (showBackupFix) buildCheckCorruptBackups(),
const Divider(),
const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton(),

View File

@@ -0,0 +1,521 @@
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.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/backup/services/backup_verification.service.dart';
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.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:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class BackupOptionsPage extends HookConsumerWidget {
const BackupOptionsPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
final settingsService = ref.watch(appSettingsServiceProvider);
final showBackupFix = Platform.isAndroid &&
settingsService.getSetting(AppSettingsEnum.advancedTroubleshooting);
final ignoreIcloudAssets = useState(
settingsService.getSetting(AppSettingsEnum.ignoreIcloudAssets),
);
final appRefreshDisabled =
Platform.isIOS && settings?.appRefreshEnabled != true;
final checkInProgress = useState(false);
Future<void> performDeletion(List<Asset> assets) async {
try {
checkInProgress.value = true;
ImmichToast.show(
context: context,
msg: "Deleting ${assets.length} assets on the server...",
);
await ref
.read(assetProvider.notifier)
.deleteAssets(assets, force: true);
ImmichToast.show(
context: context,
msg: "Deleted ${assets.length} assets on the server. "
"You can now start a manual backup",
toastType: ToastType.success,
);
} finally {
checkInProgress.value = false;
}
}
void performBackupCheck() async {
try {
checkInProgress.value = true;
if (backupState.allUniqueAssets.length >
backupState.selectedAlbumsBackupAssetsIds.length) {
ImmichToast.show(
context: context,
msg: "Backup all assets before starting this check!",
toastType: ToastType.error,
);
return;
}
final connection = await Connectivity().checkConnectivity();
if (connection != ConnectivityResult.wifi) {
ImmichToast.show(
context: context,
msg: "Make sure to be connected to unmetered Wi-Fi",
toastType: ToastType.error,
);
return;
}
WakelockPlus.enable();
const limit = 100;
final toDelete = await ref
.read(backupVerificationServiceProvider)
.findWronglyBackedUpAssets(limit: limit);
if (toDelete.isEmpty) {
ImmichToast.show(
context: context,
msg: "Did not find any corrupt asset backups!",
toastType: ToastType.success,
);
} else {
await showDialog(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => performDeletion(toDelete),
title: "Corrupt backups!",
ok: "Delete",
content:
"Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
"Run the check again to find more.\n"
"Do you want to delete the corrupt asset backups now?",
),
);
}
} finally {
WakelockPlus.disable();
checkInProgress.value = false;
}
}
Widget buildCheckCorruptBackups() {
return ListTile(
leading: Icon(
Icons.warning_rounded,
color: context.primaryColor,
),
title: const Text(
"Check for corrupt asset backups",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
isThreeLine: true,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Run this check only over Wi-Fi and once all assets "
"have been backed-up. The procedure might take a few minutes."),
ElevatedButton(
onPressed: checkInProgress.value ? null : performBackupCheck,
child: checkInProgress.value
? const CircularProgressIndicator()
: const Text("Perform check"),
),
],
),
);
}
void showErrorToUser(String msg) {
final snackBar = SnackBar(
content: Text(
msg.tr(),
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
),
backgroundColor: Colors.red,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showBatteryOptimizationInfoToUser() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text(
'backup_controller_page_background_battery_info_title',
).tr(),
content: SingleChildScrollView(
child: const Text(
'backup_controller_page_background_battery_info_message',
).tr(),
),
actions: [
ElevatedButton(
onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication,
),
child: const Text(
"backup_controller_page_background_battery_info_link",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
),
ElevatedButton(
child: const Text(
'backup_controller_page_background_battery_info_ok',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
onPressed: () {
context.pop();
},
),
],
);
},
);
}
Widget buildBackgroundBackupController() {
final bool isBackgroundEnabled = backupState.backgroundBackup;
final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging;
final Color activeColor = context.primaryColor;
String formatBackupDelaySliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_seconds'.tr(args: const ['5']);
} else if (v == 1.0) {
return 'setting_notifications_notify_seconds'.tr(args: const ['30']);
} else if (v == 2.0) {
return 'setting_notifications_notify_minutes'.tr(args: const ['2']);
} else {
return 'setting_notifications_notify_minutes'.tr(args: const ['10']);
}
}
int backupDelayToMilliseconds(double v) {
if (v == 0.0) {
return 5000;
} else if (v == 1.0) {
return 30000;
} else if (v == 2.0) {
return 120000;
} else {
return 600000;
}
}
double backupDelayToSliderValue(int ms) {
if (ms == 5000) {
return 0.0;
} else if (ms == 30000) {
return 1.0;
} else if (ms == 120000) {
return 2.0;
} else {
return 3.0;
}
}
final triggerDelay =
useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
return Column(
children: [
ListTile(
isThreeLine: true,
leading: isBackgroundEnabled
? Icon(
Icons.cloud_sync_rounded,
color: activeColor,
)
: const Icon(Icons.cloud_sync_rounded),
title: Text(
isBackgroundEnabled
? "backup_controller_page_background_is_on"
: "backup_controller_page_background_is_off",
style: context.textTheme.titleSmall,
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isBackgroundEnabled)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: const Text(
"backup_controller_page_background_description",
).tr(),
),
if (isBackgroundEnabled && Platform.isAndroid)
SwitchListTile.adaptive(
title: const Text("backup_controller_page_background_wifi")
.tr(),
secondary: Icon(
Icons.wifi,
color: isWifiRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isWifiRequired,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
),
if (isBackgroundEnabled)
SwitchListTile.adaptive(
title:
const Text("backup_controller_page_background_charging")
.tr(),
secondary: Icon(
Icons.charging_station,
color: isChargingRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isChargingRequired,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
),
if (isBackgroundEnabled && Platform.isAndroid)
ListTile(
isThreeLine: false,
dense: true,
title: const Text(
'backup_controller_page_background_delay',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(
args: [formatBackupDelaySliderValue(triggerDelay.value)],
),
subtitle: Slider(
value: triggerDelay.value,
onChanged: (double v) => triggerDelay.value = v,
onChangeEnd: (double v) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
triggerDelay: backupDelayToMilliseconds(v),
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
max: 3.0,
divisions: 3,
label: formatBackupDelaySliderValue(triggerDelay.value),
activeColor: context.primaryColor,
),
),
ElevatedButton(
onPressed: () => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
enabled: !isBackgroundEnabled,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
child: Text(
isBackgroundEnabled
? "backup_controller_page_background_turn_off"
: "backup_controller_page_background_turn_on",
style: context.textTheme.labelLarge?.copyWith(
color: context.isDarkTheme ? Colors.black : Colors.white,
),
).tr(),
),
],
),
),
if (isBackgroundEnabled && Platform.isIOS)
FutureBuilder(
future: ref
.read(backgroundServiceProvider)
.getIOSBackgroundAppRefreshEnabled(),
builder: (context, snapshot) {
final enabled = snapshot.data;
// If it's not enabled, show them some kind of alert that says
// background refresh is not enabled
if (enabled != null && !enabled) {}
// If it's enabled, no need to bother them
return Container();
},
),
if (Platform.isIOS && isBackgroundEnabled && settings != null)
IosDebugInfoTile(
settings: settings,
),
],
);
}
Widget buildBackgroundAppRefreshWarning() {
return ListTile(
isThreeLine: true,
leading: const Icon(
Icons.task_outlined,
),
title: const Text(
'backup_controller_page_background_app_refresh_disabled_title',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: const Text(
'backup_controller_page_background_app_refresh_disabled_content',
).tr(),
),
ElevatedButton(
onPressed: () => openAppSettings(),
child: const Text(
'backup_controller_page_background_app_refresh_enable_button_text',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
).tr(),
),
],
),
);
}
ListTile buildAutoBackupController() {
final isAutoBackup = backupState.autoBackup;
final backUpOption = isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
final backupBtnText = isAutoBackup
? "backup_controller_page_turn_off".tr()
: "backup_controller_page_turn_on".tr();
return ListTile(
isThreeLine: true,
leading: isAutoBackup
? Icon(
Icons.cloud_done_rounded,
color: context.primaryColor,
)
: const Icon(Icons.cloud_off_rounded),
title: Text(
backUpOption,
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isAutoBackup)
const Text(
"backup_controller_page_desc_backup",
).tr(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ElevatedButton(
onPressed: () => ref
.read(backupProvider.notifier)
.setAutoBackup(!isAutoBackup),
child: Text(
backupBtnText,
style: context.textTheme.labelLarge?.copyWith(
color: context.isDarkTheme ? Colors.black : Colors.white,
),
),
),
),
],
),
),
);
}
void switchChanged(bool value) {
settingsService.setSetting(AppSettingsEnum.ignoreIcloudAssets, value);
ignoreIcloudAssets.value = value;
ref.invalidate(appSettingsServiceProvider);
}
buildIgnoreIcloudAssetSetting() {
return [
const Divider(),
SwitchListTile.adaptive(
title: const Text(
"Ignore iCloud photos",
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text(
"Photos that are stored on iCloud will not be uploaded to the Immich server",
),
value: ignoreIcloudAssets.value,
onChanged: switchChanged,
),
];
}
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text(
"Backup options",
),
leading: IconButton(
onPressed: () {
context.autoPop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
),
),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 32.0),
child: ListView(
children: [
buildAutoBackupController(),
const Divider(),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Platform.isIOS
? (appRefreshDisabled
? buildBackgroundAppRefreshWarning()
: buildBackgroundBackupController())
: buildBackgroundBackupController(),
),
if (Platform.isIOS) ...buildIgnoreIcloudAssetSetting(),
if (showBackupFix) const Divider(),
if (showBackupFix) buildCheckCorruptBackups(),
],
),
),
);
}
}

View File

@@ -17,6 +17,6 @@ final favoriteAssetsProvider = StreamProvider<RenderList>((ref) {
.filter()
.isFavoriteEqualTo(true)
.isTrashedEqualTo(false)
.sortByFileCreatedAt();
.sortByFileCreatedAtDesc();
return renderListGenerator(query, ref);
});

View File

@@ -1,31 +1,16 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
final processing = useState(false);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
AppBar buildAppBar() {
return AppBar(
leading: IconButton(
@@ -40,66 +25,14 @@ class FavoritesPage extends HookConsumerWidget {
);
}
void unfavorite() async {
try {
if (selection.value.isNotEmpty) {
await handleFavoriteAssets(
ref,
context,
selection.value.toList(),
shouldFavorite: false,
);
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
Widget buildBottomBar() {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Card(
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
leading: const Icon(
Icons.star_border,
),
title: const Text(
"Unfavorite",
style: TextStyle(fontSize: 14),
),
onTap: processing.value ? null : unfavorite,
),
),
),
),
);
}
return Scaffold(
appBar: buildAppBar(),
body: ref.watch(favoriteAssetsProvider).widgetWhen(
onData: (data) => data.isEmpty
? Center(
child: Text('favorites_page_no_favorites'.tr()),
)
: Stack(
children: [
ImmichAssetGrid(
renderList: data,
selectionActive: selectionEnabledHook.value,
listener: selectionListener,
),
if (selectionEnabledHook.value) buildBottomBar(),
],
),
),
appBar: ref.watch(multiselectProvider) ? null : buildAppBar(),
body: MultiselectGrid(
renderListProvider: favoriteAssetsProvider,
favoriteEnabled: true,
editEnabled: true,
unfavorite: true,
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({
@@ -13,24 +14,25 @@ class DisableMultiSelectButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
return Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton.icon(
onPressed: () {
onPressed();
},
onPressed: () => onPressed(),
icon: const Icon(Icons.close_rounded),
label: Text(
'$selectedItemCount',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
style: context.textTheme.titleMedium?.copyWith(
height: 2.5,
color: context.isDarkTheme ? Colors.black : Colors.white,
),
),
),
),
),
);
}
}

View File

@@ -33,8 +33,6 @@ class ImmichAssetGrid extends HookConsumerWidget {
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
const ImmichAssetGrid({
super.key,
@@ -55,8 +53,6 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
});
@override
@@ -121,8 +117,6 @@ class ImmichAssetGrid extends HookConsumerWidget {
shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll,
showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
),
);
}

View File

@@ -39,8 +39,6 @@ class ImmichAssetGridView extends StatefulWidget {
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
const ImmichAssetGridView({
super.key,
@@ -61,8 +59,6 @@ class ImmichAssetGridView extends StatefulWidget {
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
});
@override
@@ -143,8 +139,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
showStorageIndicator: widget.showStorageIndicator,
heroOffset: widget.heroOffset,
showStack: widget.showStack,
isOwner: widget.isOwner,
sharedAlbumId: widget.sharedAlbumId,
);
}

View File

@@ -14,14 +14,12 @@ class ThumbnailImage extends StatelessWidget {
final int totalAssets;
final bool showStorageIndicator;
final bool showStack;
final bool isOwner;
final bool useGrayBoxPlaceholder;
final bool isSelected;
final bool multiselectEnabled;
final Function? onSelect;
final Function? onDeselect;
final int heroOffset;
final String? sharedAlbumId;
const ThumbnailImage({
Key? key,
@@ -31,8 +29,6 @@ class ThumbnailImage extends StatelessWidget {
required this.totalAssets,
this.showStorageIndicator = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
this.useGrayBoxPlaceholder = false,
this.isSelected = false,
this.multiselectEnabled = false,
@@ -185,8 +181,6 @@ class ThumbnailImage extends StatelessWidget {
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
),
);
}

View File

@@ -2,6 +2,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
@@ -12,33 +14,39 @@ import 'package:immich_mobile/shared/models/album.dart';
class ControlBottomAppBar extends ConsumerWidget {
final void Function(bool shareLocal) onShare;
final void Function() onFavorite;
final void Function() onArchive;
final void Function() onDelete;
final void Function()? onFavorite;
final void Function()? onArchive;
final void Function()? onDelete;
final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum;
final void Function() onUpload;
final void Function() onStack;
final void Function()? onStack;
final void Function()? onEditTime;
final void Function()? onEditLocation;
final void Function()? onRemoveFromAlbum;
final List<Album> albums;
final List<Album> sharedAlbums;
final bool enabled;
final bool unfavorite;
final bool unarchive;
final SelectionAssetState selectionAssetState;
const ControlBottomAppBar({
Key? key,
required this.onShare,
required this.onFavorite,
required this.onArchive,
required this.onDelete,
required this.sharedAlbums,
required this.albums,
this.onFavorite,
this.onArchive,
this.onDelete,
required this.onAddToAlbum,
required this.onCreateNewAlbum,
required this.onUpload,
required this.onStack,
this.onStack,
this.onEditTime,
this.onEditLocation,
this.onRemoveFromAlbum,
this.selectionAssetState = const SelectionAssetState(),
this.enabled = true,
this.unarchive = false,
this.unfavorite = false,
}) : super(key: key);
@override
@@ -48,6 +56,8 @@ class ControlBottomAppBar extends ConsumerWidget {
var hasLocal = selectionAssetState.hasLocal;
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider);
List<Widget> renderActionButtons() {
return [
@@ -62,44 +72,73 @@ class ControlBottomAppBar extends ConsumerWidget {
label: "control_bottom_app_bar_share_to".tr(),
onPressed: enabled ? () => onShare(true) : null,
),
if (hasRemote)
if (hasRemote && onArchive != null)
ControlBoxButton(
iconData: Icons.archive,
label: "control_bottom_app_bar_archive".tr(),
iconData: unarchive ? Icons.unarchive : Icons.archive,
label: (unarchive
? "control_bottom_app_bar_unarchive"
: "control_bottom_app_bar_archive")
.tr(),
onPressed: enabled ? onArchive : null,
),
if (hasRemote)
if (hasRemote && onFavorite != null)
ControlBoxButton(
iconData: Icons.favorite_border_rounded,
label: "control_bottom_app_bar_favorite".tr(),
iconData: unfavorite
? Icons.favorite_border_rounded
: Icons.favorite_rounded,
label: (unfavorite
? "control_bottom_app_bar_unfavorite"
: "control_bottom_app_bar_favorite")
.tr(),
onPressed: enabled ? onFavorite : null,
),
ControlBoxButton(
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(),
onPressed: enabled
? () {
if (!trashEnabled) {
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(
onDelete: onDelete,
);
},
);
} else {
onDelete();
if (hasRemote && onEditTime != null)
ControlBoxButton(
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".tr(),
onPressed: enabled ? onEditTime : null,
),
if (hasRemote && onEditLocation != null)
ControlBoxButton(
iconData: Icons.edit_location_alt_outlined,
label: "control_bottom_app_bar_edit_location".tr(),
onPressed: enabled ? onEditLocation : null,
),
if (onDelete != null)
ControlBoxButton(
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(),
onPressed: enabled
? () {
if (!trashEnabled) {
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(
onDelete: onDelete!,
);
},
);
} else {
onDelete!();
}
}
}
: null,
),
if (!hasLocal && selectionAssetState.selectedCount > 1)
: null,
),
if (!hasLocal &&
selectionAssetState.selectedCount > 1 &&
onStack != null)
ControlBoxButton(
iconData: Icons.filter_none_rounded,
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
if (onRemoveFromAlbum != null)
ControlBoxButton(
iconData: Icons.delete_sweep_rounded,
label: 'album_viewer_appbar_share_remove'.tr(),
onPressed: enabled ? onRemoveFromAlbum : null,
),
if (hasLocal)
ControlBoxButton(
iconData: Icons.backup_outlined,

View File

@@ -1,57 +1,30 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.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/modules/asset_viewer/services/asset_stack.service.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false);
final selectionAssetState = useState(const SelectionAssetState());
final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider);
final albumService = ref.watch(albumServiceProvider);
final currentUser = ref.watch(currentUserProvider);
final timelineUsers = ref.watch(timelineUsersIdsProvider);
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final tipOneOpacity = useState(0.0);
final refreshCount = useState(0);
final processing = useProcessingOverlay();
useEffect(
() {
@@ -61,364 +34,82 @@ class HomePage extends HookConsumerWidget {
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.read(serverInfoProvider.notifier).getServerInfo();
selectionEnabledHook.addListener(() {
multiselectEnabled.state = selectionEnabledHook.value;
});
return () {
// This does not work in tests
if (kReleaseMode) {
selectionEnabledHook.dispose();
}
};
return;
},
[],
);
Widget buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
Widget buildBody() {
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
selectionAssetState.value =
SelectionAssetState.fromSelection(selectedAssets);
}
errorBuilder(String? msg) => msg != null && msg.isNotEmpty
? () => ImmichToast.show(
context: context,
msg: msg,
gravity: ToastGravity.BOTTOM,
)
: null;
Iterable<Asset> remoteOnly(
Iterable<Asset> assets, {
void Function()? errorCallback,
}) {
final bool onlyRemote = assets.every((e) => e.isRemote);
if (!onlyRemote) {
if (errorCallback != null) errorCallback();
return assets.where((a) => a.isRemote);
}
return assets;
}
Iterable<Asset> ownedOnly(
Iterable<Asset> assets, {
void Function()? errorCallback,
}) {
if (currentUser == null) return [];
final userId = currentUser.isarId;
final bool onlyOwned = assets.every((e) => e.ownerId == userId);
if (!onlyOwned) {
if (errorCallback != null) errorCallback();
return assets.where((a) => a.ownerId == userId);
}
return assets;
}
Iterable<Asset> ownedRemoteSelection({
String? localErrorMessage,
String? ownerErrorMessage,
}) {
final assets = selection.value;
return remoteOnly(
ownedOnly(assets, errorCallback: errorBuilder(ownerErrorMessage)),
errorCallback: errorBuilder(localErrorMessage),
);
}
Iterable<Asset> remoteSelection({String? errorMessage}) => remoteOnly(
selection.value,
errorCallback: errorBuilder(errorMessage),
);
void onShareAssets(bool shareLocal) {
processing.value = true;
if (shareLocal) {
handleShareAssets(ref, context, selection.value.toList());
} else {
final ids =
remoteSelection(errorMessage: "home_page_share_err_local".tr())
.map((e) => e.remoteId!);
context.autoPush(SharedLinkEditRoute(assetsList: ids.toList()));
}
processing.value = false;
selectionEnabledHook.value = false;
}
void onFavoriteAssets() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
await handleFavoriteAssets(ref, context, remoteAssets.toList());
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onArchiveAsset() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_archive_err_local'.tr(),
ownerErrorMessage: 'home_page_archive_err_partner'.tr(),
);
await handleArchiveAssets(ref, context, remoteAssets.toList());
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onDelete() async {
processing.value = true;
try {
final toDelete = ownedOnly(
selection.value,
errorCallback: errorBuilder('home_page_delete_err_partner'.tr()),
).toList();
await ref
.read(assetProvider.notifier)
.deleteAssets(toDelete, force: !trashEnabled);
final hasRemote = toDelete.any((a) => a.isRemote);
final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset';
final trashOrRemoved =
!trashEnabled ? 'deleted permanently' : 'trashed';
if (hasRemote) {
ImmichToast.show(
context: context,
msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
gravity: ToastGravity.BOTTOM,
);
}
selectionEnabledHook.value = false;
} finally {
processing.value = false;
}
}
void onUpload() {
processing.value = true;
selectionEnabledHook.value = false;
try {
ref.read(manualUploadProvider.notifier).uploadAssets(
context,
selection.value.where((a) => a.storage == AssetState.local),
);
} finally {
processing.value = false;
}
}
void onAddToAlbum(Album album) async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(
errorMessage: "home_page_add_to_album_err_local".tr(),
);
if (assets.isEmpty) {
return;
}
final result = await albumService.addAdditionalAssetToAlbum(
assets,
album,
);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_conflicts".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
"failed": result.alreadyInAlbum.length.toString(),
},
),
);
} else {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_success".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
},
),
toastType: ToastType.success,
);
ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(albumDetailProvider(album.id));
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onCreateNewAlbum() async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(
errorMessage: "home_page_add_to_album_err_local".tr(),
);
if (assets.isEmpty) {
return;
}
final result =
await albumService.createAlbumWithGeneratedName(assets);
if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false;
context.autoPush(AlbumViewerRoute(albumId: result.id));
}
} finally {
processing.value = false;
}
}
void onStack() async {
try {
processing.value = true;
if (!selectionEnabledHook.value || selection.value.length < 2) {
return;
}
final parent = selection.value.elementAt(0);
selection.value.remove(parent);
await ref.read(assetStackServiceProvider).updateStack(
parent,
childrenToAdd: selection.value.toList(),
);
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (timelineUsers.length > 1) {
await ref.read(assetProvider.notifier).getPartnerAssets();
}
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: 4), () => refreshCount.value = 0);
}
}
buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ImmichLoadingIndicator(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'home_page_building_timeline',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: context.primaryColor,
),
).tr(),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: tipOneOpacity.value,
child: SizedBox(
width: 250,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: const Text(
'home_page_first_time_notice',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 12,
),
).tr(),
),
),
),
],
),
);
}
return SafeArea(
top: true,
bottom: false,
child: Stack(
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ref
.watch(
timelineUsers.length > 1
? multiUserAssetsProvider(timelineUsers)
: assetsProvider(currentUser?.isarId),
)
.when(
data: (data) => data.isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: refreshAssets,
topWidget:
(currentUser != null && currentUser.memoryEnabled)
? const MemoryLane()
: const SizedBox(),
showStack: true,
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator,
const ImmichLoadingIndicator(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'home_page_building_timeline',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: context.primaryColor,
),
).tr(),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: tipOneOpacity.value,
child: SizedBox(
width: 250,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: const Text(
'home_page_first_time_notice',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 12,
),
).tr(),
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
onShare: onShareAssets,
onFavorite: onFavoriteAssets,
onArchive: onArchiveAsset,
onDelete: onDelete,
onAddToAlbum: onAddToAlbum,
albums: albums,
sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum,
onUpload: onUpload,
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
onStack: onStack,
),
),
],
),
);
}
Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (timelineUsers.length > 1) {
await ref.read(assetProvider.notifier).getPartnerAssets();
}
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: 4), () => refreshCount.value = 0);
}
}
Widget buildBody() {
return MultiselectGrid(
renderListProvider: timelineUsers.length > 1
? multiUserAssetsProvider(timelineUsers)
: assetsProvider(currentUser?.isarId),
buildLoadingIndicator: buildLoadingIndicator,
onRefresh: refreshAssets,
stackEnabled: true,
archiveEnabled: true,
editEnabled: true,
);
}
return Scaffold(
appBar: !selectionEnabledHook.value ? const ImmichAppBar() : null,
appBar: ref.watch(multiselectProvider) ? null : const ImmichAppBar(),
body: buildBody(),
);
}

View File

@@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -21,6 +23,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier(
this._apiService,
this._db,
this._ref,
) : super(
AuthenticationState(
deviceId: "",
@@ -36,6 +39,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final ApiService _apiService;
final Isar _db;
final StateNotifierProviderRef<AuthenticationNotifier, AuthenticationState>
_ref;
final _log = Logger("AuthenticationNotifier");
Future<bool> login(
@@ -111,6 +116,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
]);
_ref.invalidate(albumProvider);
_ref.invalidate(sharedAlbumProvider);
state = state.copyWith(
deviceId: "",
@@ -222,5 +229,6 @@ final authenticationProvider =
return AuthenticationNotifier(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref,
);
});

View File

@@ -0,0 +1,113 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:latlong2/latlong.dart';
class MapLocationPickerPage extends HookConsumerWidget {
final LatLng? initialLatLng;
const MapLocationPickerPage({super.key, this.initialLatLng});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedLatLng = useState<LatLng>(initialLatLng ?? LatLng(0, 0));
final isDarkTheme =
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
final isLoading =
ref.watch(mapStateNotifier.select((state) => state.isLoading));
final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
return Theme(
// Override app theme based on map theme
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: Scaffold(
extendBodyBehindAppBar: true,
body: Stack(
children: [
if (!isLoading)
FlutterMap(
options: MapOptions(
maxBounds:
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
interactiveFlags: InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom,
center: LatLng(20, 20),
zoom: 2,
minZoom: 1,
maxZoom: maxZoom,
onTap: (tapPosition, point) => selectedLatLng.value = point,
),
children: [
ref.read(mapStateNotifier.notifier).getTileLayer(),
MarkerLayer(
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: selectedLatLng.value,
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
height: 40,
width: 40,
),
],
),
],
),
if (isLoading)
Positioned(
top: context.height * 0.35,
left: context.width * 0.425,
child: const ImmichLoadingIndicator(),
),
],
),
bottomSheet: BottomSheet(
onClosing: () {},
builder: (context) => SizedBox(
height: 150,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}",
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w600,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => context.autoPop(selectedLatLng.value),
child: const Text("map_location_picker_page_use_location")
.tr(),
),
ElevatedButton(
onPressed: () => context.autoPop(),
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error,
),
child: const Text("action_common_cancel").tr(),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/utils/map_controller_hook.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -12,13 +14,15 @@ class MapThumbnail extends HookConsumerWidget {
final double zoom;
final List<Marker> markers;
final double height;
final double width;
final bool showAttribution;
final bool isDarkTheme;
const MapThumbnail({
super.key,
required this.coords,
required this.height,
this.height = 100,
this.width = 100,
this.onTap,
this.zoom = 1,
this.showAttribution = true,
@@ -28,18 +32,33 @@ class MapThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapController = useMapController();
final isMapReady = useRef(false);
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
useEffect(
() {
if (isMapReady.value && mapController.center != coords) {
mapController.move(coords, zoom);
}
return null;
},
[coords],
);
return SizedBox(
height: height,
width: width,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: FlutterMap(
mapController: mapController,
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: coords,
zoom: zoom,
onTap: onTap,
onMapReady: () => isMapReady.value = true,
),
nonRotatedChildren: [
if (showAttribution)

View File

@@ -0,0 +1,32 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/flutter_map.dart';
MapController useMapController({
String? debugLabel,
List<Object?>? keys,
}) {
return use(_MapControllerHook(keys: keys));
}
class _MapControllerHook extends Hook<MapController> {
const _MapControllerHook({List<Object?>? keys}) : super(keys: keys);
@override
HookState<MapController, Hook<MapController>> createState() =>
_MapControllerHookState();
}
class _MapControllerHookState
extends HookState<MapController, _MapControllerHook> {
late final controller = MapController();
@override
MapController build(BuildContext context) => controller;
@override
void dispose() => controller.dispose();
@override
String get debugLabel => 'useMapController';
}

View File

@@ -55,6 +55,7 @@ class MapPageState extends ConsumerState<MapPage> {
// in onMapEvent() since MapEventMove#id is not populated properly in the
// current version of flutter_map(4.0.0) used
bool forceAssetUpdate = false;
bool isMapReady = false;
late final Debounce debounce;
@override
@@ -79,7 +80,7 @@ class MapPageState extends ConsumerState<MapPage> {
bool forceReload = false,
}) {
try {
final bounds = mapController.bounds;
final bounds = isMapReady ? mapController.bounds : null;
if (bounds != null) {
final oldAssetsInBounds = assetsInBounds.toSet();
assetsInBounds =
@@ -455,6 +456,7 @@ class MapPageState extends ConsumerState<MapPage> {
minZoom: 1,
maxZoom: maxZoom,
onMapReady: () {
isMapReady = true;
mapController.mapEventStream.listen(onMapEvent);
},
),

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class PartnerDetailPage extends HookConsumerWidget {
@@ -15,7 +15,6 @@ class PartnerDetailPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(assetsProvider(partner.isarId));
final inTimeline = useState(partner.inTimeline);
bool toggleInProcess = false;
@@ -57,33 +56,30 @@ class PartnerDetailPage extends HookConsumerWidget {
}
return Scaffold(
appBar: AppBar(
title: Text(partner.name),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: toggleInTimeline,
icon: Icon(
inTimeline.value ? Icons.collections : Icons.collections_outlined,
appBar: ref.watch(multiselectProvider)
? null
: AppBar(
title: Text(partner.name),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: toggleInTimeline,
icon: Icon(
inTimeline.value
? Icons.collections
: Icons.collections_outlined,
),
tooltip: "Show/hide photos on your main timeline",
),
],
),
tooltip: "Show/hide photos on your main timeline",
),
],
),
body: assets.widgetWhen(
onData: (renderList) => renderList.isEmpty
? Padding(
padding: const EdgeInsets.all(16),
child: Text(
"It seems ${partner.name} does not have any photos...\n"
"Or your server version does not match the app version."),
)
: ImmichAssetGrid(
renderList: renderList,
onRefresh: () =>
ref.read(assetProvider.notifier).getPartnerAssets(partner),
),
body: MultiselectGrid(
renderListProvider: assetsProvider(partner.isarId),
onRefresh: () =>
ref.read(assetProvider.notifier).getPartnerAssets(partner),
deleteEnabled: false,
favoriteEnabled: false,
),
);
}

View File

@@ -6,7 +6,7 @@ part of 'person.service.dart';
// RiverpodGenerator
// **************************************************************************
String _$personServiceHash() => r'3fc3dcf4603c7b55c0deae65f39f6c212eea492b';
String _$personServiceHash() => r'cde0a9c029d16ddde2adcd58ae8c863bf8cc1fed';
/// See also [personService].
@ProviderFor(personService)

View File

@@ -29,9 +29,8 @@ class CuratedPlacesRow extends CuratedRow {
onTap: () => context.autoPush(
const MapRoute(),
),
child: SizedBox(
height: imageSize,
width: imageSize,
child: SizedBox.square(
dimension: imageSize,
child: Stack(
children: [
Padding(
@@ -43,6 +42,7 @@ class CuratedPlacesRow extends CuratedRow {
5,
),
height: imageSize,
width: imageSize,
showAttribution: false,
isDarkTheme: context.isDarkTheme,
),

View File

@@ -7,7 +7,7 @@ part of 'app_settings.provider.dart';
// **************************************************************************
String _$appSettingsServiceHash() =>
r'957a65af6967701112f3076b507f9738fec4b7be';
r'45ea609a91d250290431a7a08a14d16b37c7515d';
/// See also [appSettingsService].
@ProviderFor(appSettingsService)

View File

@@ -51,6 +51,12 @@ enum AppSettingsEnum<T> {
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
selectedAlbumSortReverse<bool>(
StoreKey.selectedAlbumSortReverse,
null,
false,
),
;
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class CustomTransitionsBuilders {
const CustomTransitionsBuilders._();
static const ZoomPageTransitionsBuilder zoomPageTransitionsBuilder =
ZoomPageTransitionsBuilder();
static const RouteTransitionsBuilder zoomedPage = _zoomedPage;
static Widget _zoomedPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return zoomPageTransitionsBuilder.buildTransitions(
// Empty PageRoute<> object, only used to pass allowSnapshotting to ZoomPageTransitionsBuilder
PageRouteBuilder(
allowSnapshotting: true,
fullscreenDialog: false,
pageBuilder: (context, animation, secondaryAnimation) =>
const SizedBox.shrink(),
),
context,
animation,
secondaryAnimation,
child,
);
}
}

View File

@@ -8,6 +8,8 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_options_page.dart';
import 'package:immich_mobile/modules/map/ui/map_location_picker.dart';
import 'package:immich_mobile/modules/map/views/map_page.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
@@ -43,6 +45,7 @@ import 'package:immich_mobile/modules/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/routing/custom_transition_builders.dart';
import 'package:immich_mobile/routing/duplicate_guard.dart';
import 'package:immich_mobile/routing/backup_permission_guard.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -56,7 +59,8 @@ import 'package:immich_mobile/shared/views/app_log_page.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' hide LatLng;
import 'package:latlong2/latlong.dart';
part 'router.gr.dart';
@@ -86,9 +90,10 @@ part 'router.gr.dart';
],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
AutoRoute(
CustomRoute(
page: GalleryViewerPage,
guards: [AuthGuard, DuplicateGuard],
transitionsBuilder: CustomTransitionsBuilders.zoomedPage,
),
AutoRoute(page: VideoViewerPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(
@@ -170,6 +175,11 @@ part 'router.gr.dart';
transitionsBuilder: TransitionsBuilders.slideLeft,
durationInMilliseconds: 200,
),
CustomRoute<LatLng?>(
page: MapLocationPickerPage,
guards: [AuthGuard, DuplicateGuard],
),
AutoRoute(page: BackupOptionsPage, guards: [AuthGuard, DuplicateGuard]),
],
)
class AppRouter extends _$AppRouter {

View File

@@ -63,7 +63,7 @@ class _$AppRouter extends RootStackRouter {
},
GalleryViewerRoute.name: (routeData) {
final args = routeData.argsAs<GalleryViewerRouteArgs>();
return MaterialPageX<dynamic>(
return CustomPage<dynamic>(
routeData: routeData,
child: GalleryViewerPage(
key: args.key,
@@ -72,9 +72,10 @@ class _$AppRouter extends RootStackRouter {
totalAssets: args.totalAssets,
heroOffset: args.heroOffset,
showStack: args.showStack,
isOwner: args.isOwner,
sharedAlbumId: args.sharedAlbumId,
),
transitionsBuilder: CustomTransitionsBuilders.zoomedPage,
opaque: true,
barrierDismissible: false,
);
},
VideoViewerRoute.name: (routeData) {
@@ -357,6 +358,25 @@ class _$AppRouter extends RootStackRouter {
barrierDismissible: false,
);
},
MapLocationPickerRoute.name: (routeData) {
final args = routeData.argsAs<MapLocationPickerRouteArgs>(
orElse: () => const MapLocationPickerRouteArgs());
return CustomPage<LatLng?>(
routeData: routeData,
child: MapLocationPickerPage(
key: args.key,
initialLatLng: args.initialLatLng,
),
opaque: true,
barrierDismissible: false,
);
},
BackupOptionsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const BackupOptionsPage(),
);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
@@ -701,6 +721,22 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
MapLocationPickerRoute.name,
path: '/map-location-picker-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
BackupOptionsRoute.name,
path: '/backup-options-page',
guards: [
authGuard,
duplicateGuard,
],
),
];
}
@@ -775,8 +811,6 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
required int totalAssets,
int heroOffset = 0,
bool showStack = false,
bool isOwner = true,
String? sharedAlbumId,
}) : super(
GalleryViewerRoute.name,
path: '/gallery-viewer-page',
@@ -787,8 +821,6 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
),
);
@@ -803,8 +835,6 @@ class GalleryViewerRouteArgs {
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
});
final Key? key;
@@ -819,13 +849,9 @@ class GalleryViewerRouteArgs {
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
@override
String toString() {
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner, sharedAlbumId: $sharedAlbumId}';
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}';
}
}
@@ -1618,6 +1644,52 @@ class ActivitiesRouteArgs {
}
}
/// generated route for
/// [MapLocationPickerPage]
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
MapLocationPickerRoute({
Key? key,
LatLng? initialLatLng,
}) : super(
MapLocationPickerRoute.name,
path: '/map-location-picker-page',
args: MapLocationPickerRouteArgs(
key: key,
initialLatLng: initialLatLng,
),
);
static const String name = 'MapLocationPickerRoute';
}
class MapLocationPickerRouteArgs {
const MapLocationPickerRouteArgs({
this.key,
this.initialLatLng,
});
final Key? key;
final LatLng? initialLatLng;
@override
String toString() {
return 'MapLocationPickerRouteArgs{key: $key, initialLatLng: $initialLatLng}';
}
}
/// generated route for
/// [BackupOptionsPage]
class BackupOptionsRoute extends PageRouteInfo<void> {
const BackupOptionsRoute()
: super(
BackupOptionsRoute.name,
path: '/backup-options-page',
);
static const String name = 'BackupOptionsRoute';
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@@ -1,5 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:flutter/foundation.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';
@@ -43,11 +42,6 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
RenderList _renderList = RenderList.empty();
@ignore
RenderList get renderList => _renderList;
@ignore
bool get isRemote => remoteId != null;
@@ -75,17 +69,6 @@ class Album {
return name.join(' ');
}
Stream<void> watchRenderList(GroupAssetsBy groupAssetsBy) async* {
final query =
assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
await for (final _ in query.watchLazy()) {
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
}
}
@override
bool operator ==(other) {
if (other is! Album) return false;

View File

@@ -256,6 +256,8 @@ class Asset {
isFavorite != a.isFavorite ||
isArchived != a.isArchived ||
isTrashed != a.isTrashed ||
a.exifInfo?.latitude != exifInfo?.latitude ||
a.exifInfo?.longitude != exifInfo?.longitude ||
// no local stack count or different count from remote
((stackCount == null && a.stackCount != null) ||
(stackCount != null &&

View File

@@ -182,6 +182,8 @@ enum StoreKey<T> {
mapRelativeDate<int>(119, type: int),
selfSignedCert<bool>(120, type: bool),
mapIncludeArchived<bool>(121, type: bool),
ignoreIcloudAssets<bool>(122, type: bool),
selectedAlbumSortReverse<bool>(123, type: bool),
;
const StoreKey(

View File

@@ -6,7 +6,7 @@ part of 'api.provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$apiServiceHash() => r'03cbd33147a7058d56175e532ac47e1aa4858c6d';
String _$apiServiceHash() => r'5b8beddb448316bdae5e3963ff77601653715729';
/// See also [apiService].
@ProviderFor(apiService)

View File

@@ -202,7 +202,8 @@ class AssetNotifier extends StateNotifier<bool> {
return isSuccess ? remote : [];
}
Future<void> toggleFavorite(List<Asset> assets, bool status) async {
Future<void> toggleFavorite(List<Asset> assets, [bool? status]) async {
status ??= !assets.every((a) => a.isFavorite);
final newAssets = await _assetService.changeFavoriteStatus(assets, status);
for (Asset? newAsset in newAssets) {
if (newAsset == null) {
@@ -212,7 +213,8 @@ class AssetNotifier extends StateNotifier<bool> {
}
}
Future<void> toggleArchive(List<Asset> assets, bool status) async {
Future<void> toggleArchive(List<Asset> assets, [bool? status]) async {
status ??= assets.every((a) => a.isArchived);
final newAssets = await _assetService.changeArchiveStatus(assets, status);
int i = 0;
for (Asset oldAsset in assets) {

View File

@@ -1,10 +1,13 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/server_info/server_version.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
@@ -14,13 +17,33 @@ import 'package:socket_io_client/socket_io_client.dart';
enum PendingAction {
assetDelete,
assetUploaded,
assetHidden,
}
class PendingChange {
final String id;
final PendingAction action;
final dynamic value;
const PendingChange(this.action, this.value);
const PendingChange(
this.id,
this.action,
this.value,
);
@override
String toString() => 'PendingChange(id: $id, action: $action, value: $value)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PendingChange && other.id == id && other.action == action;
}
@override
int get hashCode => id.hashCode ^ action.hashCode;
}
class WebsocketState {
@@ -131,6 +154,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_hidden', _handleOnAssetHidden);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
@@ -163,35 +187,78 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
void addPendingChange(PendingAction action, dynamic value) {
final now = DateTime.now();
state = state.copyWith(
pendingChanges: [...state.pendingChanges, PendingChange(action, value)],
pendingChanges: [
...state.pendingChanges,
PendingChange(now.millisecondsSinceEpoch.toString(), action, value),
],
);
_debounce(handlePendingChanges);
}
void handlePendingChanges() {
Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete)
.toList();
if (deleteChanges.isNotEmpty) {
List<String> remoteIds =
deleteChanges.map((a) => a.value.toString()).toList();
_ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
state = state.copyWith(
pendingChanges: state.pendingChanges
.where((c) => c.action != PendingAction.assetDelete)
.whereNot((c) => deleteChanges.contains(c))
.toList(),
);
}
}
void _handleOnUploadSuccess(dynamic data) {
final dto = AssetResponseDto.fromJson(data);
if (dto != null) {
final newAsset = Asset.remote(dto);
_ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
Future<void> _handlePendingUploaded() async {
final uploadedChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetUploaded)
.toList();
if (uploadedChanges.isNotEmpty) {
List<AssetResponseDto?> remoteAssets = uploadedChanges
.map((a) => AssetResponseDto.fromJson(a.value))
.toList();
for (final dto in remoteAssets) {
if (dto != null) {
final newAsset = Asset.remote(dto);
await _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
}
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => uploadedChanges.contains(c))
.toList(),
);
}
}
Future<void> _handlingPendingHidden() async {
final hiddenChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetHidden)
.toList();
if (hiddenChanges.isNotEmpty) {
List<String> remoteIds =
hiddenChanges.map((a) => a.value.toString()).toList();
final db = _ref.watch(dbProvider);
await db.writeTxn(() => db.assets.deleteAllByRemoteId(remoteIds));
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => hiddenChanges.contains(c))
.toList(),
);
}
}
void handlePendingChanges() async {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
}
void _handleOnConfigUpdate(dynamic _) {
_ref.read(serverInfoProvider.notifier).getServerFeatures();
_ref.read(serverInfoProvider.notifier).getServerConfig();
@@ -202,10 +269,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_ref.read(assetProvider.notifier).getAllAsset();
}
void _handleOnAssetDelete(dynamic data) {
addPendingChange(PendingAction.assetDelete, data);
_debounce(handlePendingChanges);
}
void _handleOnUploadSuccess(dynamic data) =>
addPendingChange(PendingAction.assetUploaded, data);
void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data);
_handleReleaseUpdates(dynamic data) {
// Json guard

View File

@@ -11,6 +11,7 @@ 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:isar/isar.dart';
import 'package:latlong2/latlong.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -181,4 +182,27 @@ class AssetService {
Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) {
return updateAssets(assets, UpdateAssetDto(isArchived: isArchive));
}
Future<List<Asset?>> changeDateTime(
List<Asset> assets,
String updatedDt,
) {
return updateAssets(
assets,
UpdateAssetDto(dateTimeOriginal: updatedDt),
);
}
Future<List<Asset?>> changeLocation(
List<Asset> assets,
LatLng location,
) {
return updateAssets(
assets,
UpdateAssetDto(
latitude: location.latitude,
longitude: location.longitude,
),
);
}
}

View File

@@ -401,6 +401,10 @@ class SyncService {
final Album a = await Album.remote(dto);
await _db.writeTxn(() => _db.albums.store(a));
} else {
_log.warning(
"Failed to add album from server: assetCount ${dto.assetCount} != "
"asset array length ${dto.assets.length} for album ${dto.albumName}");
}
}

View File

@@ -0,0 +1,425 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.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/modules/asset_viewer/services/asset_stack.service.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.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.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class MultiselectGrid extends HookConsumerWidget {
const MultiselectGrid({
Key? key,
required this.renderListProvider,
this.onRefresh,
this.buildLoadingIndicator,
this.onRemoveFromAlbum,
this.topWidget,
this.stackEnabled = false,
this.archiveEnabled = false,
this.deleteEnabled = true,
this.favoriteEnabled = true,
this.editEnabled = false,
this.unarchive = false,
this.unfavorite = false,
}) : super(key: key);
final ProviderListenable<AsyncValue<RenderList>> renderListProvider;
final Future<void> Function()? onRefresh;
final Widget Function()? buildLoadingIndicator;
final Future<bool> Function(Iterable<Asset>)? onRemoveFromAlbum;
final Widget? topWidget;
final bool stackEnabled;
final bool archiveEnabled;
final bool unarchive;
final bool deleteEnabled;
final bool favoriteEnabled;
final bool unfavorite;
final bool editEnabled;
Widget buildDefaultLoadingIndicator() =>
const Center(child: ImmichLoadingIndicator());
Widget buildEmptyIndicator() =>
const Center(child: Text("No assets to show"));
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false);
final selectionAssetState = useState(const SelectionAssetState());
final selection = useState(<Asset>{});
final currentUser = ref.watch(currentUserProvider);
final processing = useProcessingOverlay();
useEffect(
() {
selectionEnabledHook.addListener(() {
multiselectEnabled.state = selectionEnabledHook.value;
});
return () {
// This does not work in tests
if (kReleaseMode) {
selectionEnabledHook.dispose();
}
};
},
[],
);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
selectionAssetState.value =
SelectionAssetState.fromSelection(selectedAssets);
}
errorBuilder(String? msg) => msg != null && msg.isNotEmpty
? () => ImmichToast.show(
context: context,
msg: msg,
gravity: ToastGravity.BOTTOM,
)
: null;
Iterable<Asset> remoteOnly(
Iterable<Asset> assets, {
void Function()? errorCallback,
}) {
final bool onlyRemote = assets.every((e) => e.isRemote);
if (!onlyRemote) {
if (errorCallback != null) errorCallback();
return assets.where((a) => a.isRemote);
}
return assets;
}
Iterable<Asset> ownedOnly(
Iterable<Asset> assets, {
void Function()? errorCallback,
}) {
if (currentUser == null) return [];
final userId = currentUser.isarId;
final bool onlyOwned = assets.every((e) => e.ownerId == userId);
if (!onlyOwned) {
if (errorCallback != null) errorCallback();
return assets.where((a) => a.ownerId == userId);
}
return assets;
}
Iterable<Asset> ownedRemoteSelection({
String? localErrorMessage,
String? ownerErrorMessage,
}) {
final assets = selection.value;
return remoteOnly(
ownedOnly(assets, errorCallback: errorBuilder(ownerErrorMessage)),
errorCallback: errorBuilder(localErrorMessage),
);
}
Iterable<Asset> remoteSelection({String? errorMessage}) => remoteOnly(
selection.value,
errorCallback: errorBuilder(errorMessage),
);
void onShareAssets(bool shareLocal) {
processing.value = true;
if (shareLocal) {
handleShareAssets(ref, context, selection.value.toList());
} else {
final ids =
remoteSelection(errorMessage: "home_page_share_err_local".tr())
.map((e) => e.remoteId!);
context.autoPush(SharedLinkEditRoute(assetsList: ids.toList()));
}
processing.value = false;
selectionEnabledHook.value = false;
}
void onFavoriteAssets() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
await handleFavoriteAssets(ref, context, remoteAssets.toList());
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onArchiveAsset() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_archive_err_local'.tr(),
ownerErrorMessage: 'home_page_archive_err_partner'.tr(),
);
await handleArchiveAssets(ref, context, remoteAssets.toList());
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onDelete() async {
processing.value = true;
try {
final trashEnabled =
ref.read(serverInfoProvider.select((v) => v.serverFeatures.trash));
final toDelete = ownedOnly(
selection.value,
errorCallback: errorBuilder('home_page_delete_err_partner'.tr()),
).toList();
await ref
.read(assetProvider.notifier)
.deleteAssets(toDelete, force: !trashEnabled);
final hasRemote = toDelete.any((a) => a.isRemote);
final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset';
final trashOrRemoved =
!trashEnabled ? 'deleted permanently' : 'trashed';
if (hasRemote) {
ImmichToast.show(
context: context,
msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
gravity: ToastGravity.BOTTOM,
);
}
selectionEnabledHook.value = false;
} finally {
processing.value = false;
}
}
void onUpload() {
processing.value = true;
selectionEnabledHook.value = false;
try {
ref.read(manualUploadProvider.notifier).uploadAssets(
context,
selection.value.where((a) => a.storage == AssetState.local),
);
} finally {
processing.value = false;
}
}
void onAddToAlbum(Album album) async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(
errorMessage: "home_page_add_to_album_err_local".tr(),
);
if (assets.isEmpty) {
return;
}
final result =
await ref.read(albumServiceProvider).addAdditionalAssetToAlbum(
assets,
album,
);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_conflicts".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
"failed": result.alreadyInAlbum.length.toString(),
},
),
);
} else {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_success".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
},
),
toastType: ToastType.success,
);
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onCreateNewAlbum() async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(
errorMessage: "home_page_add_to_album_err_local".tr(),
);
if (assets.isEmpty) {
return;
}
final result = await ref
.read(albumServiceProvider)
.createAlbumWithGeneratedName(assets);
if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false;
context.autoPush(AlbumViewerRoute(albumId: result.id));
}
} finally {
processing.value = false;
}
}
void onStack() async {
try {
processing.value = true;
if (!selectionEnabledHook.value || selection.value.length < 2) {
return;
}
final parent = selection.value.elementAt(0);
selection.value.remove(parent);
await ref.read(assetStackServiceProvider).updateStack(
parent,
childrenToAdd: selection.value.toList(),
);
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onEditTime() async {
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
handleEditDateTime(ref, context, remoteAssets.toList());
}
} finally {
selectionEnabledHook.value = false;
}
}
void onEditLocation() async {
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
handleEditLocation(ref, context, remoteAssets.toList());
}
} finally {
selectionEnabledHook.value = false;
}
}
Future<T> Function() wrapLongRunningFun<T>(
Future<T> Function() fun, {
bool showOverlay = true,
}) =>
() async {
if (showOverlay) processing.value = true;
try {
final result = await fun();
if (result.runtimeType != bool || result == true) {
selectionEnabledHook.value = false;
}
return result;
} finally {
if (showOverlay) processing.value = false;
}
};
return SafeArea(
top: true,
bottom: false,
child: Stack(
children: [
ref.watch(renderListProvider).when(
data: (data) => data.isEmpty &&
(buildLoadingIndicator != null || topWidget == null)
? (buildLoadingIndicator ?? buildEmptyIndicator)()
: ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: onRefresh == null
? null
: wrapLongRunningFun(
onRefresh!,
showOverlay: false,
),
topWidget: topWidget,
showStack: stackEnabled,
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator,
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
onShare: onShareAssets,
onFavorite: favoriteEnabled ? onFavoriteAssets : null,
onArchive: archiveEnabled ? onArchiveAsset : null,
onDelete: deleteEnabled ? onDelete : null,
onAddToAlbum: onAddToAlbum,
onCreateNewAlbum: onCreateNewAlbum,
onUpload: onUpload,
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
onStack: stackEnabled ? onStack : null,
onEditTime: editEnabled ? onEditTime : null,
onEditLocation: editEnabled ? onEditLocation : null,
unfavorite: unfavorite,
unarchive: unarchive,
onRemoveFromAlbum: onRemoveFromAlbum != null
? wrapLongRunningFun(
() => onRemoveFromAlbum!(selection.value),
)
: null,
),
],
),
);
}
}

View File

@@ -0,0 +1,260 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/timezone.dart';
Future<String?> showDateTimePicker({
required BuildContext context,
DateTime? initialDateTime,
String? initialTZ,
Duration? initialTZOffset,
}) {
return showDialog<String?>(
context: context,
builder: (context) => _DateTimePicker(
initialDateTime: initialDateTime,
initialTZ: initialTZ,
initialTZOffset: initialTZOffset,
),
);
}
String _getFormattedOffset(int offsetInMilli, tz.Location location) {
return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
}
class _DateTimePicker extends HookWidget {
final DateTime? initialDateTime;
final String? initialTZ;
final Duration? initialTZOffset;
const _DateTimePicker({
this.initialDateTime,
this.initialTZ,
this.initialTZOffset,
});
_TimeZoneOffset _getInitiationLocation() {
if (initialTZ != null) {
try {
return _TimeZoneOffset.fromLocation(
tz.timeZoneDatabase.get(initialTZ!),
);
} on LocationNotFoundException {
// no-op
}
}
Duration? tzOffset = initialTZOffset ?? initialDateTime?.timeZoneOffset;
if (tzOffset != null) {
final offsetInMilli = tzOffset.inMilliseconds;
// get all locations with matching offset
final locations = tz.timeZoneDatabase.locations.values.where(
(location) => location.currentTimeZone.offset == offsetInMilli,
);
// Prefer locations with abbreviation first
final location = locations.firstWhereOrNull(
(e) => !e.currentTimeZone.abbreviation.contains("0"),
) ??
locations.firstOrNull;
if (location != null) {
return _TimeZoneOffset.fromLocation(location);
}
}
return _TimeZoneOffset.fromLocation(tz.getLocation("UTC"));
}
// returns a list of location<name> along with it's offset in duration
List<_TimeZoneOffset> getAllTimeZones() {
return tz.timeZoneDatabase.locations.values
.where((l) => !l.currentTimeZone.abbreviation.contains("0"))
.map(_TimeZoneOffset.fromLocation)
.sorted()
.toList();
}
@override
Widget build(BuildContext context) {
final date = useState<DateTime>(initialDateTime ?? DateTime.now());
final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation());
final timeZones = useMemoized(() => getAllTimeZones(), const []);
void pickDate() async {
final now = DateTime.now();
// Handles cases where the date from the asset is far off in the future
final initialDate = date.value.isAfter(now) ? now : date.value;
final newDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(1800),
lastDate: now,
);
if (newDate == null) {
return;
}
final newTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(date.value),
);
if (newTime == null) {
return;
}
date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute);
}
void popWithDateTime() {
final formattedDateTime =
DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value);
final dtWithOffset = formattedDateTime +
Duration(milliseconds: tzOffset.value.offsetInMilliseconds)
.formatAsOffset();
context.pop(dtWithOffset);
}
return AlertDialog(
contentPadding: const EdgeInsets.all(30),
alignment: Alignment.center,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"edit_date_time_dialog_date_time",
textAlign: TextAlign.center,
).tr(),
TextButton.icon(
onPressed: pickDate,
icon: Text(
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
style: context.textTheme.bodyLarge
?.copyWith(color: context.primaryColor),
),
label: const Icon(
Icons.edit_outlined,
size: 18,
),
),
const Text(
"edit_date_time_dialog_timezone",
textAlign: TextAlign.center,
).tr(),
DropdownMenu(
menuHeight: 300,
width: 280,
inputDecorationTheme: const InputDecorationTheme(
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
trailingIcon: Padding(
padding: const EdgeInsets.only(right: 10),
child: Icon(
Icons.arrow_drop_down,
color: context.primaryColor,
),
),
textStyle: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
menuStyle: const MenuStyle(
fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)),
alignment: Alignment(-1.25, 0.5),
),
onSelected: (value) => tzOffset.value = value!,
initialSelection: tzOffset.value,
dropdownMenuEntries: timeZones
.map(
(t) => DropdownMenuEntry<_TimeZoneOffset>(
value: t,
label: t.display,
style: ButtonStyle(
textStyle: MaterialStatePropertyAll(
context.textTheme.bodyMedium,
),
),
),
)
.toList(),
),
],
),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(
"action_common_cancel",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.error,
),
).tr(),
),
TextButton(
onPressed: popWithDateTime,
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
),
],
);
}
}
class _TimeZoneOffset implements Comparable<_TimeZoneOffset> {
final String display;
final Location location;
const _TimeZoneOffset({
required this.display,
required this.location,
});
_TimeZoneOffset copyWith({
String? display,
Location? location,
}) {
return _TimeZoneOffset(
display: display ?? this.display,
location: location ?? this.location,
);
}
int get offsetInMilliseconds => location.currentTimeZone.offset;
_TimeZoneOffset.fromLocation(tz.Location l)
: display = _getFormattedOffset(l.currentTimeZone.offset, l),
location = l;
@override
int compareTo(_TimeZoneOffset other) {
return offsetInMilliseconds.compareTo(other.offsetInMilliseconds);
}
@override
String toString() =>
'_TimeZoneOffset(display: $display, location: $location)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is _TimeZoneOffset &&
other.display == display &&
other.offsetInMilliseconds == offsetInMilliseconds;
}
@override
int get hashCode =>
display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode;
}

View File

@@ -0,0 +1,256 @@
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:flutter_map/plugin_api.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:latlong2/latlong.dart';
Future<LatLng?> showLocationPicker({
required BuildContext context,
LatLng? initialLatLng,
}) {
return showDialog<LatLng?>(
context: context,
useRootNavigator: false,
builder: (ctx) => _LocationPicker(
initialLatLng: initialLatLng,
),
);
}
enum _LocationPickerMode { map, manual }
bool _validateLat(String value) {
final l = double.tryParse(value);
return l != null && l > -90 && l < 90;
}
bool _validateLong(String value) {
final l = double.tryParse(value);
return l != null && l > -180 && l < 180;
}
class _LocationPicker extends HookWidget {
final LatLng? initialLatLng;
const _LocationPicker({
this.initialLatLng,
});
@override
Widget build(BuildContext context) {
final latitude = useState(initialLatLng?.latitude ?? 0.0);
final longitude = useState(initialLatLng?.longitude ?? 0.0);
final latlng = LatLng(latitude.value, longitude.value);
final pickerMode = useState(_LocationPickerMode.map);
final latitudeController = useTextEditingController();
final isValidLatitude = useState(true);
final latitiudeFocusNode = useFocusNode();
final longitudeController = useTextEditingController();
final longitudeFocusNode = useFocusNode();
final isValidLongitude = useState(true);
void validateInputs() {
isValidLatitude.value = _validateLat(latitudeController.text);
if (isValidLatitude.value) {
latitude.value = latitudeController.text.toDouble();
}
isValidLongitude.value = _validateLong(longitudeController.text);
if (isValidLongitude.value) {
longitude.value = longitudeController.text.toDouble();
}
}
void validateAndPop() {
if (pickerMode.value == _LocationPickerMode.manual) {
validateInputs();
}
if (isValidLatitude.value && isValidLongitude.value) {
return context.pop(latlng);
}
}
List<Widget> buildMapPickerMode() {
return [
TextButton.icon(
icon: Text(
"${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}",
),
label: const Icon(Icons.edit_outlined, size: 16),
onPressed: () {
latitudeController.text = latitude.value.toStringAsFixed(4);
longitudeController.text = longitude.value.toStringAsFixed(4);
pickerMode.value = _LocationPickerMode.manual;
},
),
const SizedBox(
height: 12,
),
MapThumbnail(
coords: latlng,
height: 200,
width: 200,
zoom: 6,
showAttribution: false,
onTap: (p0, p1) async {
final newLatLng = await context.autoPush<LatLng?>(
MapLocationPickerRoute(initialLatLng: latlng),
);
if (newLatLng != null) {
latitude.value = newLatLng.latitude;
longitude.value = newLatLng.longitude;
}
},
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
latitude.value,
longitude.value,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
],
),
];
}
List<Widget> buildManualPickerMode() {
return [
TextButton.icon(
icon: const Text("location_picker_choose_on_map").tr(),
label: const Icon(Icons.map_outlined, size: 16),
onPressed: () {
validateInputs();
if (isValidLatitude.value && isValidLongitude.value) {
pickerMode.value = _LocationPickerMode.map;
}
},
),
const SizedBox(
height: 12,
),
TextField(
controller: latitudeController,
focusNode: latitiudeFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'location_picker_latitude'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.auto,
border: const OutlineInputBorder(),
hintText: 'location_picker_latitude_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
errorText: isValidLatitude.value
? null
: "location_picker_latitude_error".tr(),
),
onEditingComplete: () {
isValidLatitude.value = _validateLat(latitudeController.text);
if (isValidLatitude.value) {
latitude.value = latitudeController.text.toDouble();
longitudeFocusNode.requestFocus();
}
},
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [LengthLimitingTextInputFormatter(8)],
onTapOutside: (_) => latitiudeFocusNode.unfocus(),
),
const SizedBox(
height: 24,
),
TextField(
controller: longitudeController,
focusNode: longitudeFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'location_picker_longitude'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.auto,
border: const OutlineInputBorder(),
hintText: 'location_picker_longitude_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
errorText: isValidLongitude.value
? null
: "location_picker_longitude_error".tr(),
),
onEditingComplete: () {
isValidLongitude.value = _validateLong(longitudeController.text);
if (isValidLongitude.value) {
longitude.value = longitudeController.text.toDouble();
longitudeFocusNode.unfocus();
}
},
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [LengthLimitingTextInputFormatter(8)],
onTapOutside: (_) => longitudeFocusNode.unfocus(),
),
];
}
return AlertDialog(
contentPadding: const EdgeInsets.all(30),
alignment: Alignment.center,
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"edit_location_dialog_title",
textAlign: TextAlign.center,
).tr(),
const SizedBox(
height: 12,
),
if (pickerMode.value == _LocationPickerMode.manual)
...buildManualPickerMode(),
if (pickerMode.value == _LocationPickerMode.map)
...buildMapPickerMode(),
],
),
),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(
"action_common_cancel",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.error,
),
).tr(),
),
TextButton(
onPressed: validateAndPop,
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
),
],
);
}
}

View File

@@ -15,7 +15,7 @@ class ScaffoldErrorBody extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"scaffold_body_error_occured",
"scaffold_body_error_occurred",
style: context.textTheme.displayMedium,
textAlign: TextAlign.center,
).tr(),

View File

@@ -20,7 +20,7 @@ final immichThemeProvider = StateProvider<ThemeMode>((ref) {
}
});
ThemeData base = ThemeData(
final ThemeData base = ThemeData(
chipTheme: const ChipThemeData(
side: BorderSide.none,
),
@@ -30,7 +30,7 @@ ThemeData base = ThemeData(
),
);
ThemeData immichLightTheme = ThemeData(
final ThemeData immichLightTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primarySwatch: Colors.indigo,
@@ -153,7 +153,7 @@ ThemeData immichLightTheme = ThemeData(
),
);
ThemeData immichDarkTheme = ThemeData(
final ThemeData immichDarkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
primarySwatch: Colors.indigo,

View File

@@ -2,12 +2,17 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asset_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/date_time_picker.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/location_picker.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:latlong2/latlong.dart';
void handleShareAssets(
WidgetRef ref,
@@ -40,10 +45,11 @@ Future<void> handleArchiveAssets(
WidgetRef ref,
BuildContext context,
List<Asset> selection, {
bool shouldArchive = true,
bool? shouldArchive,
ToastGravity toastGravity = ToastGravity.BOTTOM,
}) async {
if (selection.isNotEmpty) {
shouldArchive ??= !selection.every((a) => a.isArchived);
await ref
.read(assetProvider.notifier)
.toggleArchive(selection, shouldArchive);
@@ -64,10 +70,11 @@ Future<void> handleFavoriteAssets(
WidgetRef ref,
BuildContext context,
List<Asset> selection, {
bool shouldFavorite = true,
bool? shouldFavorite,
ToastGravity toastGravity = ToastGravity.BOTTOM,
}) async {
if (selection.isNotEmpty) {
shouldFavorite ??= !selection.every((a) => a.isFavorite);
await ref
.watch(assetProvider.notifier)
.toggleFavorite(selection, shouldFavorite);
@@ -85,3 +92,60 @@ Future<void> handleFavoriteAssets(
}
}
}
Future<void> handleEditDateTime(
WidgetRef ref,
BuildContext context,
List<Asset> selection,
) async {
DateTime? initialDate;
String? timeZone;
Duration? offset;
if (selection.length == 1) {
final asset = selection.first;
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
final (dt, oft) = assetWithExif.getTZAdjustedTimeAndOffset();
initialDate = dt;
offset = oft;
timeZone = assetWithExif.exifInfo?.timeZone;
}
final dateTime = await showDateTimePicker(
context: context,
initialDateTime: initialDate,
initialTZ: timeZone,
initialTZOffset: offset,
);
if (dateTime == null) {
return;
}
ref.read(assetServiceProvider).changeDateTime(selection.toList(), dateTime);
}
Future<void> handleEditLocation(
WidgetRef ref,
BuildContext context,
List<Asset> selection,
) async {
LatLng? initialLatLng;
if (selection.length == 1) {
final asset = selection.first;
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
if (assetWithExif.exifInfo?.latitude != null &&
assetWithExif.exifInfo?.longitude != null) {
initialLatLng = LatLng(
assetWithExif.exifInfo!.latitude!,
assetWithExif.exifInfo!.longitude!,
);
}
}
final location = await showLocationPicker(
context: context,
initialLatLng: initialLatLng,
);
if (location == null) {
return;
}
ref.read(assetServiceProvider).changeLocation(selection.toList(), location);
}

View File

@@ -24,6 +24,10 @@ doc/AssetBulkUploadCheckDto.md
doc/AssetBulkUploadCheckItem.md
doc/AssetBulkUploadCheckResponseDto.md
doc/AssetBulkUploadCheckResult.md
doc/AssetFaceResponseDto.md
doc/AssetFaceUpdateDto.md
doc/AssetFaceUpdateItem.md
doc/AssetFaceWithoutPersonResponseDto.md
doc/AssetFileUploadResponseDto.md
doc/AssetIdsDto.md
doc/AssetIdsResponseDto.md
@@ -60,6 +64,8 @@ doc/DownloadInfoDto.md
doc/DownloadResponseDto.md
doc/EntityType.md
doc/ExifResponseDto.md
doc/FaceApi.md
doc/FaceDto.md
doc/FileChecksumDto.md
doc/FileChecksumResponseDto.md
doc/FileReportDto.md
@@ -100,6 +106,7 @@ doc/PersonApi.md
doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md
doc/PersonWithFacesResponseDto.md
doc/QueueStatusDto.md
doc/ReactionLevel.md
doc/ReactionType.md
@@ -177,6 +184,7 @@ lib/api/api_key_api.dart
lib/api/asset_api.dart
lib/api/audit_api.dart
lib/api/authentication_api.dart
lib/api/face_api.dart
lib/api/job_api.dart
lib/api/library_api.dart
lib/api/o_auth_api.dart
@@ -213,6 +221,10 @@ lib/model/asset_bulk_upload_check_dto.dart
lib/model/asset_bulk_upload_check_item.dart
lib/model/asset_bulk_upload_check_response_dto.dart
lib/model/asset_bulk_upload_check_result.dart
lib/model/asset_face_response_dto.dart
lib/model/asset_face_update_dto.dart
lib/model/asset_face_update_item.dart
lib/model/asset_face_without_person_response_dto.dart
lib/model/asset_file_upload_response_dto.dart
lib/model/asset_ids_dto.dart
lib/model/asset_ids_response_dto.dart
@@ -247,6 +259,7 @@ lib/model/download_info_dto.dart
lib/model/download_response_dto.dart
lib/model/entity_type.dart
lib/model/exif_response_dto.dart
lib/model/face_dto.dart
lib/model/file_checksum_dto.dart
lib/model/file_checksum_response_dto.dart
lib/model/file_report_dto.dart
@@ -282,6 +295,7 @@ lib/model/people_update_item.dart
lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart
lib/model/person_with_faces_response_dto.dart
lib/model/queue_status_dto.dart
lib/model/reaction_level.dart
lib/model/reaction_type.dart
@@ -367,6 +381,10 @@ test/asset_bulk_upload_check_dto_test.dart
test/asset_bulk_upload_check_item_test.dart
test/asset_bulk_upload_check_response_dto_test.dart
test/asset_bulk_upload_check_result_test.dart
test/asset_face_response_dto_test.dart
test/asset_face_update_dto_test.dart
test/asset_face_update_item_test.dart
test/asset_face_without_person_response_dto_test.dart
test/asset_file_upload_response_dto_test.dart
test/asset_ids_dto_test.dart
test/asset_ids_response_dto_test.dart
@@ -403,6 +421,8 @@ test/download_info_dto_test.dart
test/download_response_dto_test.dart
test/entity_type_test.dart
test/exif_response_dto_test.dart
test/face_api_test.dart
test/face_dto_test.dart
test/file_checksum_dto_test.dart
test/file_checksum_response_dto_test.dart
test/file_report_dto_test.dart
@@ -443,6 +463,7 @@ test/person_api_test.dart
test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart
test/person_with_faces_response_dto_test.dart
test/queue_status_dto_test.dart
test/reaction_level_test.dart
test/reaction_type_test.dart

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.89.0
- API version: 1.90.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -133,6 +133,8 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**logoutAuthDevices**](doc//AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices |
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face |
*FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} |
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} |
*LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /library |
@@ -153,12 +155,14 @@ Class | Method | HTTP request | Description
*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner |
*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} |
*PartnerApi* | [**updatePartner**](doc//PartnerApi.md#updatepartner) | **PUT** /partner/{id} |
*PersonApi* | [**createPerson**](doc//PersonApi.md#createperson) | **POST** /person |
*PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /person |
*PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} |
*PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
*PersonApi* | [**getPersonStatistics**](doc//PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics |
*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge |
*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign |
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person |
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} |
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
@@ -224,6 +228,10 @@ Class | Method | HTTP request | Description
- [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
- [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)
- [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
- [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
- [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md)
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
- [AssetIdsDto](doc//AssetIdsDto.md)
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
@@ -258,6 +266,7 @@ Class | Method | HTTP request | Description
- [DownloadResponseDto](doc//DownloadResponseDto.md)
- [EntityType](doc//EntityType.md)
- [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md)
- [FileChecksumDto](doc//FileChecksumDto.md)
- [FileChecksumResponseDto](doc//FileChecksumResponseDto.md)
- [FileReportDto](doc//FileReportDto.md)
@@ -293,6 +302,7 @@ Class | Method | HTTP request | Description
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)

View File

@@ -8,9 +8,12 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**dateTimeOriginal** | **String** | | [optional]
**ids** | **List<String>** | | [default to const []]
**isArchived** | **bool** | | [optional]
**isFavorite** | **bool** | | [optional]
**latitude** | **num** | | [optional]
**longitude** | **num** | | [optional]
**removeParent** | **bool** | | [optional]
**stackParentId** | **String** | | [optional]

View File

@@ -0,0 +1,22 @@
# openapi.model.AssetFaceResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**boundingBoxX1** | **int** | |
**boundingBoxX2** | **int** | |
**boundingBoxY1** | **int** | |
**boundingBoxY2** | **int** | |
**id** | **String** | |
**imageHeight** | **int** | |
**imageWidth** | **int** | |
**person** | [**PersonResponseDto**](PersonResponseDto.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

15
mobile/openapi/doc/AssetFaceUpdateDto.md generated Normal file
View File

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

View File

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

View File

@@ -0,0 +1,21 @@
# openapi.model.AssetFaceWithoutPersonResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**boundingBoxX1** | **int** | |
**boundingBoxX2** | **int** | |
**boundingBoxY1** | **int** | |
**boundingBoxY2** | **int** | |
**id** | **String** | |
**imageHeight** | **int** | |
**imageWidth** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -30,7 +30,7 @@ Name | Type | Description | Notes
**originalPath** | **String** | |
**owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional]
**ownerId** | **String** | |
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
**people** | [**List<PersonWithFacesResponseDto>**](PersonWithFacesResponseDto.md) | | [optional] [default to const []]
**resized** | **bool** | |
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
**stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [optional] [default to const []]

127
mobile/openapi/doc/FaceApi.md generated Normal file
View File

@@ -0,0 +1,127 @@
# openapi.api.FaceApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getFaces**](FaceApi.md#getfaces) | **GET** /face |
[**reassignFacesById**](FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} |
# **getFaces**
> List<AssetFaceResponseDto> getFaces(id)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = FaceApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
final result = api_instance.getFaces(id);
print(result);
} catch (e) {
print('Exception when calling FaceApi->getFaces: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
[**List<AssetFaceResponseDto>**](AssetFaceResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **reassignFacesById**
> PersonResponseDto reassignFacesById(id, faceDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = FaceApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final faceDto = FaceDto(); // FaceDto |
try {
final result = api_instance.reassignFacesById(id, faceDto);
print(result);
} catch (e) {
print('Exception when calling FaceApi->reassignFacesById: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**faceDto** | [**FaceDto**](FaceDto.md)| |
### Return type
[**PersonResponseDto**](PersonResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

15
mobile/openapi/doc/FaceDto.md generated Normal file
View File

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

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