Compare commits

..

48 Commits

Author SHA1 Message Date
alextran1502
be0a775ce5 chore: release v2.4.0 2025-12-16 16:42:54 +00:00
Alex
f0b069adb9 fix: shared link expiration and small styling (#24566)
* fix: shared link expiration and small styling

* Use text color of enable/disable shared link properties
2025-12-16 16:41:12 +00:00
Hai Sullivan
276d02e12b fix(mobile): better UI for metadata panel (#24428)
* change drag bar and animation position

* ensure bottom bar is below the metadata panel - move the bottom bar from bottomNavigationBar into the Stack

* change some parameters

* add background color for night mode

* background color

* change default position

* minor changes

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-16 16:25:01 +00:00
Yaros
ded9535434 fix(mobile): local delete missing from sheet on some routes (#24505)
* fix(mobile): local delete missing from album sheet

* chore: remove hasLocal
2025-12-16 09:54:53 -06:00
idubnori
997aec2441 feat: replace heart icons to thumbs-up across activity (#24590)
* feat: replace heart icons to thumbs-up across activity

* fix: update thumb_up icon color to use primaryColor in activity components

* chore: web colors

* chore: modify colors

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-16 15:27:09 +00:00
Ben
cb2bd47816 fix(web): immich logo in shared links (#24618)
* fix(web): immich logo in shared links

* chore: apply changes for individual shared link as well

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-12-16 14:59:17 +00:00
renovate[bot]
f1c8377ca0 chore(deps): update dependency @types/node to ^24.10.3 (#24605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 12:23:52 +01:00
Alex
8416397589 chore: revert Svelte 5.43.3 (#24509) 2025-12-16 04:03:53 +00:00
Yaros
dc29635b67 chore(mobile): changed default album sort to match with web (#24526)
chore(mobile): matched default album sort with web
2025-12-15 21:18:45 -06:00
Min Idzelis
00290e1e71 feat: make OCR store reentrant-safe (#24419) 2025-12-15 21:06:04 -06:00
Yaros
3ef4c4f315 feat(web): slideshow feature on shared albums (#24598) 2025-12-15 20:49:50 -06:00
idubnori
b10a8baf53 feat(mobile): move buttons in the bottom sheet to the kebabu menu (#24175)
* refactor: remove bottom sheet buttons

* feat: add iconOnly and menuItem parameters to action buttons

* feat: enhance action button context and kebab menu integration

* feat: use ActionButtonContext

* fix: add missing options in some cases

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-15 16:44:27 -06:00
Mees Frensel
77926383db fix(server): only extract image's duration if format supports animation (#24587) 2025-12-15 12:36:46 -05:00
Yaros
35eda735c8 fix(web): recent search doesn't use search type (#24578)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-15 12:44:00 +01:00
Diogo Correia
8f7a71d1cf fix(web): download panel being hidden by admin sidebar (#24583) 2025-12-15 12:29:18 +01:00
Yaros
33cdea88aa fix(mobile): birthday off by one day on remote (#24527) 2025-12-11 21:23:11 -06:00
Alex
4b345e02ff fix: refresh appear in list after asset is added to a current or new album (#24523) 2025-12-11 11:06:53 -06:00
Yaros
8cf900bafa fix(mobile): local videos with '#' don't play on android (#24373)
* fix(mobile): videos with '#' don't play on android

* refactor: one line

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* fix: depend on platform

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-12-11 10:57:37 -06:00
Yaros
59a3f0f455 feat(mobile): create new album from add to modal (#24431)
* feat(mobile): create new album from add to modal

* refactor: use statefulwidget instead of hook

* chore: rename createalbumbutton
2025-12-11 09:47:31 -06:00
Sergey Katsubo
c5d99711f7 fix(web): show inferred timezone in date editor (#24513)
fix(web): show inferred timezone of asset in date editor
2025-12-11 09:20:51 -06:00
Yaros
4c0a41723f feat(web): asset selection bar in tags view (#24522)
* feat(web): asset selection tab in tags view

* chore: remove unused imports
2025-12-11 15:20:29 +00:00
Bart van Velden
f73511a754 fix(docs): typo in maintenance mode command (#24518) 2025-12-11 09:19:33 -06:00
hubert-taieb
e637387082 fix(server): prevent metadata extraction failures on large video files (#24094)
* prevent  metadata extraction failures on large video files

Increases ExifTool timeout from 20s to 120s to prevent GPS metadata
extraction failures on large video files (>2GB, 10+ minutes).

Issue: Large videos timeout during metadata extraction, causing GPS
coordinates to be lost even though ExifTool can extract them given
enough time.

Testing: 2.6GB, 10:52min video that previously timed out now
successfully extracts GPS metadata.

* redundant comment

Increased task timeout for processing large videos.

* chore: lint

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-12-11 15:18:19 +00:00
renovate[bot]
baad38f0e6 fix(deps): update typescript-projects (#24476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-11 00:13:06 +00:00
Min Idzelis
161147af51 feat: timeline-manager improvement to use AssetResponseDto efficiently (#24421) 2025-12-11 01:07:31 +01:00
renovate[bot]
cbdf5011f9 chore(deps): update docker.io/valkey/valkey:9 docker digest to fb8d272 (#24474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:39 +01:00
renovate[bot]
f0f1d279c4 chore(deps): update prom/prometheus docker digest to d936808 (#24475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:20 +01:00
renovate[bot]
5821f2fe61 chore(deps): update github-actions (#24477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:03 +01:00
Noel S
4cbce072be fix(docs): slow upload speed with example nginx reverse proxy config (#24490)
* increase buffer size

* increase further

* increase buffer further
2025-12-10 22:29:36 +00:00
idubnori
5e5bb7e87d fix(mobile): versionStatus.message text overflow (#24504) 2025-12-10 16:18:55 -06:00
shenlong
b052893a1e feat(mobile): immich-ui icon button (#24502)
* feat(mobile): immich-ui icon button

* fix lint

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-10 16:18:01 -06:00
Kurt McKee
15e58595fd fix(mobile): iOS local permission dialog extra whitespace (#24491)
Fix a iOS rendering issue caused by extra whitespace
2025-12-10 16:17:08 -06:00
Alex
6d499c782a chore: update ui lib (#24483) 2025-12-09 17:27:01 -06:00
idubnori
7af99b8606 feat(mobile): move top bar buttons into kebabu menu in AssetViewer (#24461)
* chore(mobile):  i18n: "open_asset_info" in viewer kebab menu

* feat(mobile): move some top buttons into kebabu menu

* refactor(mobile): viewer kebab menu to use context-based button generation

* feat(mobile): refactor action button and kebab menu to use ConsumerWidget for improved state management

* feat(mobile): pass original theme to ViewerKebabMenu for consistent styling

* chore: styling

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-09 18:26:28 +00:00
Arnau Mora
01e39277e0 feat(mobile): Localized backup upload details page (#21136)
* Localized backup details page

# Conflicts:
#	i18n/en.json

* Format

* format fix

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-09 11:23:01 -06:00
Yaros
06e79703da fix(mobile): timeline bottom padding on selection (#24480) 2025-12-09 09:19:41 -06:00
Yaros
c360781565 fix(mobile): fix overflow text in backup card (#24448)
* fix(mobile): fix overflow text in backup card

* refactor: use intrinsicheight

* chore: fix spelling of entitycounttile
2025-12-09 09:03:29 -06:00
idubnori
287f6d5c94 fix(mobile): buttons inside AddActionButton color is the same as background color (#24460)
* fix: icon & text color in AddActionButton

* fix: use Divider
2025-12-08 14:29:31 -06:00
Simon Kubiak
fe9125a3d1 fix(web): [album table view] long album title overflows table row (#24450)
fix(web): long album title overflows vertically on album page in table view
2025-12-08 15:35:58 +00:00
Yaros
8b31936bb6 fix(mobile): cannot create album while name field is focused (#24449)
fix(mobile): create album disabled when focused
2025-12-08 09:33:01 -06:00
Sergey Katsubo
19958dfd83 fix(server): building docker image for different platforms on the same host (#24459)
Fix building docker image for different platforms on the same host

Use per-platform mise cache to avoid 'sh: 1: extism-js: not found'
This happens due to re-using cached installed binary for another platform
2025-12-08 09:15:43 -06:00
Alex
1e1cf0d1fe fix: build iOS fastlane installation (#24408) 2025-12-06 14:55:53 -06:00
Min Idzelis
879e0ea131 fix: thumbnail doesnt send mouseLeave events properly (#24423) 2025-12-06 21:52:06 +01:00
Sergey Katsubo
42136f9091 fix(server): update exiftool-vendored to v34 for more robust metadata extraction (#24424) 2025-12-06 14:45:59 -06:00
Harrison
1109c32891 fix(docs): websockets in nginx example (#24411)
Co-authored-by: Harrison <frith.harry@gmail.com>
2025-12-06 16:28:12 +00:00
idubnori
3c80049192 chore(mobile): add kebabu menu in asset viewer (#24387)
* feat(mobile): implement viewer kebab menu with about option

* feat: revert exisitng buttons, adjust label name

* unify MenuAnchor usage

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-05 19:51:59 +00:00
Hai Sullivan
8f1669efbe chore(mobile): smoother UI experience for iOS devices (#24397)
allows the tab pages to use the standard Material page transition during push/pop navigation
2025-12-05 11:02:04 -06:00
Robert Schäfer
146bf65d02 refactor(dev): remove ulimits for rootless docker (#24393)
Description
-----------

When I follow the [developer setup](https://docs.immich.app/developer/setup) I run into a permission error using rootless docker. A while ago I asked on Discord in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327/1442974448776122592) about these ulimits.

I suggest to remove the `ulimits` altogether. It seems that @ItalyPaleAle has left the setting just hoping that it could help somebody in the future. See the [PR description](https://github.com/immich-app/immich/pull/4556).

How Has This Been Tested?
-------------------------

Using rootless docker:

```
$ docker context ls
NAME         DESCRIPTION                               DOCKER ENDPOINT                     ERROR
default                                                unix:///var/run/docker.sock
rootless *                                             unix:///run/user/1000/docker.sock
```

Running `make` will fail because of permission errors:
```
$  docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
...
Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error setting rlimits for ready process: error setting rlimit type 7: operation not permitted
```

On my machine I have the following hard limit for "Maximum number of open file descriptors":
```
$ ulimit -nH
524288
```

I can confirm that the permission error is caused by the security restrictions of the operating system mentioned above:

Changing `docker/docker-compose.dev.yml` like ..

```
    ulimits:
      nofile:
        soft: 524289
        hard: 524289
```

.. will lead to a permission error whereas this ..

```
    ulimits:
      nofile:
        soft: 524288
        hard: 524288
```

.. starts fine.

Apparently the defaults for these limits are coming from [systemd](26b2085d54/man/systemd.exec.xml (L1122)) which is used on nearly every linux distribution. So my assumption is that almost any linux user who uses rootless docker will run into a permission error when starting the development setup.

Checklist:
----------

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-05 09:26:20 -05:00
166 changed files with 4210 additions and 6805 deletions

View File

@@ -108,7 +108,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
@@ -222,6 +222,7 @@ jobs:
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: '3.3' ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios working-directory: ./mobile/ios
- name: Install CocoaPods dependencies - name: Install CocoaPods dependencies
@@ -229,13 +230,6 @@ jobs:
run: | run: |
pod install pod install
- name: Install Fastlane
working-directory: ./mobile/ios
run: |
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install
- name: Create API Key - name: Create API Key
env: env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}

View File

@@ -44,7 +44,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@@ -69,7 +69,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './docs/.nvmrc' node-version-file: './docs/.nvmrc'
cache: 'pnpm' cache: 'pnpm'

View File

@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'

View File

@@ -31,7 +31,7 @@ jobs:
- name: Generate a token - name: Generate a token
id: generate_token id: generate_token
if: ${{ inputs.skip != true }} if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -49,7 +49,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -68,7 +68,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -126,7 +126,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -144,7 +144,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:
draft: true draft: true
tag_name: ${{ env.IMMICH_VERSION }} tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -36,7 +36,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -159,7 +159,7 @@ jobs:
- name: Create PR - name: Create PR
id: create-pr id: create-pr
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@@ -52,7 +52,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -80,7 +80,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:
tag_name: ${{ steps.version.outputs.result }} tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}

View File

@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './open-api/typescript-sdk/.nvmrc' node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

@@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -121,7 +121,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -168,7 +168,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -210,7 +210,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -254,7 +254,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -292,7 +292,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -340,7 +340,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -387,7 +387,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -426,7 +426,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -481,7 +481,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -617,7 +617,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './.github/.nvmrc' node-version-file: './.github/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -668,7 +668,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -730,7 +730,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'

248
CHANGELOG.md Normal file
View File

@@ -0,0 +1,248 @@
# v2.4.0
# v2.4.0
## Highlights
Welcome to the release `v2.4.0` of Immich. This release focuses on bug fixes, QoL improvements, and polished UI components across mobile and the web. Let's dive right in.
* Show the owner's name in the shared album
* Command palette
* Change search type directly in the search bar
* Job details
* Simplify the top control bar in the mobile app
* Notable fix: fix an issue where metadata extraction could fail on high concurrency
### Show the owner's name in the shared album.
On the web, in shared albums, you can now toggle an option to display the asset's owner name at the bottom right corner of the thumbnail.
![](/api/attachments.redirect?id=3e4d661e-4015-4b04-b8f3-a055e9d4db01 " =1071x758")
### Command palette
The web app now has an integrated command palette, which can be opened `ctrl + k` on Windows/Linux or `cmd + k` on macOS. This first iteration of command pallets lets you quickly navigate between administration pages by typing the name of the page you want to go to.
![](/api/attachments.redirect?id=e431bca6-78fb-4873-879c-2a61af4a6df0 " =1354x1032")
### Change search type directly in the search bar
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### 🫥 Deprecated Changes
* feat: queues by @jrasm91 in <https://github.com/immich-app/immich/pull/24142>
### 🚀 Features
* feat: improve performance: don't sort timeline buckets from server by @midzelis in <https://github.com/immich-app/immich/pull/24032>
* feat: command palette by @danieldietzler in <https://github.com/immich-app/immich/pull/23693>
* feat(web): Shared album owner labels by @xCJPECKOVERx in <https://github.com/immich-app/immich/pull/21171>
* feat(mobile): persist album sorting & layout in settings by @YarosMallorca in <https://github.com/immich-app/immich/pull/22133>
* feat: queue detail page by @jrasm91 in <https://github.com/immich-app/immich/pull/24352>
* chore(mobile): add kebabu menu in asset viewer by @idubnori in <https://github.com/immich-app/immich/pull/24387>
### 🌟 Enhancements
* feat(web): allow navigating the map with arrow keys by @lukashass in <https://github.com/immich-app/immich/pull/24080>
* feat: separate camera and lens info in detail panel by @fabianbees in <https://github.com/immich-app/immich/pull/23670>
* feat(web): shared link card tweaks by @jrasm91 in <https://github.com/immich-app/immich/pull/24192>
* feat(server): exclude syncthing folders from external libraries by @SaphuA in <https://github.com/immich-app/immich/pull/24240>
* feat(web): search type selection dropdown by @YarosMallorca in <https://github.com/immich-app/immich/pull/24091>
* feat: header context menu by @jrasm91 in <https://github.com/immich-app/immich/pull/24374>
* feat(mobile): move top bar buttons into kebabu menu in AssetViewer by @idubnori in <https://github.com/immich-app/immich/pull/24461>
### 🐛 Bug fixes
* fix: effect loop by @jrasm91 in <https://github.com/immich-app/immich/pull/24014>
* fix: do not clear hash on updated_at change by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24039>
* fix: disable animation "add to" action menu by @bwees in <https://github.com/immich-app/immich/pull/24040>
* fix: Use correct app store link by @Mraedis in <https://github.com/immich-app/immich/pull/24062>
* fix: show archived assets in favorite page by @bwees in <https://github.com/immich-app/immich/pull/24052>
* fix(mobile): first video memory on page doesn't play by @YarosMallorca in <https://github.com/immich-app/immich/pull/23906>
* feat(web): show detected faces in spherical photos by @meesfrensel in <https://github.com/immich-app/immich/pull/23974>
* fix: add users to album by @danieldietzler in <https://github.com/immich-app/immich/pull/24133>
* fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params by @lutostag in <https://github.com/immich-app/immich/pull/23333>
* fix: use proper updatedAt value in local assets by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24137>
* fix: albums page reactivity loops by @danieldietzler in <https://github.com/immich-app/immich/pull/24046>
* fix: getAspectRatio fallback to db width and height by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24131>
* fix(web): fix support & feedback modal wrapping by @Snowknight26 in <https://github.com/immich-app/immich/pull/24018>
* fix: don't get OCR data in shared link by @alextran1502 in <https://github.com/immich-app/immich/pull/24152>
* fix: duration extraction by @jrasm91 in <https://github.com/immich-app/immich/pull/24178>
* fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures by @LukaPrebil in <https://github.com/immich-app/immich/pull/24045>
* fix: update timeline-manager after archive actions by @midzelis in <https://github.com/immich-app/immich/pull/24010>
* fix: theme switcher by @jrasm91 in <https://github.com/immich-app/immich/pull/24209>
* fix: label 'for' attributes in user-api-key-grid by @kimsey0 in <https://github.com/immich-app/immich/pull/24232>
* fix(mobile): enable backup text overflows by @YarosMallorca in <https://github.com/immich-app/immich/pull/24227>
* fix(web): integrate zoom toggle button into panorama photo viewer by @meesfrensel in <https://github.com/immich-app/immich/pull/24189>
* fix(web): use full tag path when creating nested subtags by @NiklasvonM in <https://github.com/immich-app/immich/pull/24249>
* fix: only generate memory based on users assets by @alextran1502 in <https://github.com/immich-app/immich/pull/24151>
* fix(mobile): docs link by @mmomjian in <https://github.com/immich-app/immich/pull/24277>
* fix(server): use bigrams for cjk by @mertalev in <https://github.com/immich-app/immich/pull/24285>
* fix(ml): do not upscale preview by @mertalev in <https://github.com/immich-app/immich/pull/24322>
* fix(web): open onboarding documentation link in new tab by @carbonemys in <https://github.com/immich-app/immich/pull/24289>
* fix(mobile): use correct timezone displayed in the info sheet by @kao-byte in <https://github.com/immich-app/immich/pull/24310>
* fix(web): folder view sort oder by @etnoy in <https://github.com/immich-app/immich/pull/24337>
* fix(server): do not delete offline assets by @mertalev in <https://github.com/immich-app/immich/pull/24355>
* fix: exposure info and better readability by @alextran1502 in <https://github.com/immich-app/immich/pull/24344>
* fix: Adjust the zoom level by @jforseth210 in <https://github.com/immich-app/immich/pull/24353>
* fix: local full sync on Android on resume by @alextran1502 in <https://github.com/immich-app/immich/pull/24348>
* fix(web): Add minimum content size to logo for consistent visual on small screens by @kiloomar in <https://github.com/immich-app/immich/pull/24372>
* fix: use adjustment time in iOS for hash reset by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24047>
* fix(server): update exiftool-vendored to v34 for more robust metadata extraction by @skatsubo in <https://github.com/immich-app/immich/pull/24424>
* fix(mobile): cannot create album while name field is focused by @YarosMallorca in <https://github.com/immich-app/immich/pull/24449>
* fix(web): \[album table view\] long album title overflows table row by @simonkub in <https://github.com/immich-app/immich/pull/24450>
* fix(mobile): fix overflow text in backup card by @YarosMallorca in <https://github.com/immich-app/immich/pull/24448>
* fix(mobile): timeline bottom padding on selection by @YarosMallorca in <https://github.com/immich-app/immich/pull/24480>
* feat(mobile): Localized backup upload details page by @ArnyminerZ in <https://github.com/immich-app/immich/pull/21136>
### 📚 Documentation
* docs: DB_STORAGE_TYPE is only used by the database container by @dionysius in <https://github.com/immich-app/immich/pull/24215>
* fix(docs): build `cli` for e2e tests by @roschaefer in <https://github.com/immich-app/immich/pull/24184>
* docs(faq): add more info on archiving by @etnoy in <https://github.com/immich-app/immich/pull/24326>
* fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT by @dionysius in <https://github.com/immich-app/immich/pull/24335>
* fix: prevent OOM on nginx reverse proxy servers by @NicholasFlamy in <https://github.com/immich-app/immich/pull/24351>
* fix(docs): obsolete docs about rootless docker by @roschaefer in <https://github.com/immich-app/immich/pull/24376>
* fix(docs): websockets in nginx example by @fourthwall in <https://github.com/immich-app/immich/pull/24411>
### 🌐 Translations
* chore: add new language requests by @danieldietzler in <https://github.com/immich-app/immich/pull/23991>
## New Contributors
* @ujjwal123123 made their first contribution in <https://github.com/immich-app/immich/pull/24101>
* @lutostag made their first contribution in <https://github.com/immich-app/immich/pull/23333>
* @LukaPrebil made their first contribution in <https://github.com/immich-app/immich/pull/24045>
* @kimsey0 made their first contribution in <https://github.com/immich-app/immich/pull/24232>
* @SaphuA made their first contribution in <https://github.com/immich-app/immich/pull/24240>
* @dionysius made their first contribution in <https://github.com/immich-app/immich/pull/24215>
* @NiklasvonM made their first contribution in <https://github.com/immich-app/immich/pull/24249>
* @kao-byte made their first contribution in <https://github.com/immich-app/immich/pull/24098>
* @carbonemys made their first contribution in <https://github.com/immich-app/immich/pull/24289>
* @kiloomar made their first contribution in <https://github.com/immich-app/immich/pull/24372>
* @fourthwall made their first contribution in <https://github.com/immich-app/immich/pull/24411>
* @simonkub made their first contribution in <https://github.com/immich-app/immich/pull/24450>
* @ArnyminerZ made their first contribution in <https://github.com/immich-app/immich/pull/21136>
**Full Changelog**: <https://github.com/immich-app/immich/compare/v2.3.1...v2.4.0>
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### 🫥 Deprecated Changes
* feat: queues by @jrasm91 in https://github.com/immich-app/immich/pull/24142
### 🚀 Features
* feat: improve performance: don't sort timeline buckets from server by @midzelis in https://github.com/immich-app/immich/pull/24032
* feat: command palette by @danieldietzler in https://github.com/immich-app/immich/pull/23693
* feat(web): Shared album owner labels by @xCJPECKOVERx in https://github.com/immich-app/immich/pull/21171
* feat(mobile): persist album sorting & layout in settings by @YarosMallorca in https://github.com/immich-app/immich/pull/22133
* feat: queue detail page by @jrasm91 in https://github.com/immich-app/immich/pull/24352
* chore(mobile): add kebabu menu in asset viewer by @idubnori in https://github.com/immich-app/immich/pull/24387
* feat(mobile): create new album from add to modal by @YarosMallorca in https://github.com/immich-app/immich/pull/24431
* feat(mobile): move buttons in the bottom sheet to the kebabu menu by @idubnori in https://github.com/immich-app/immich/pull/24175
### 🌟 Enhancements
* feat(web): allow navigating the map with arrow keys by @lukashass in https://github.com/immich-app/immich/pull/24080
* feat: separate camera and lens info in detail panel by @fabianbees in https://github.com/immich-app/immich/pull/23670
* feat(web): shared link card tweaks by @jrasm91 in https://github.com/immich-app/immich/pull/24192
* feat(server): exclude syncthing folders from external libraries by @SaphuA in https://github.com/immich-app/immich/pull/24240
* feat(web): search type selection dropdown by @YarosMallorca in https://github.com/immich-app/immich/pull/24091
* feat: header context menu by @jrasm91 in https://github.com/immich-app/immich/pull/24374
* feat(mobile): move top bar buttons into kebabu menu in AssetViewer by @idubnori in https://github.com/immich-app/immich/pull/24461
* feat(web): asset selection bar in tags view by @YarosMallorca in https://github.com/immich-app/immich/pull/24522
* feat(web): slideshow feature on shared albums by @YarosMallorca in https://github.com/immich-app/immich/pull/24598
* feat: replace heart icons to thumbs-up across activity by @idubnori in https://github.com/immich-app/immich/pull/24590
### 🐛 Bug fixes
* fix: effect loop by @jrasm91 in https://github.com/immich-app/immich/pull/24014
* fix: do not clear hash on updated_at change by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24039
* fix: disable animation "add to" action menu by @bwees in https://github.com/immich-app/immich/pull/24040
* fix: Use correct app store link by @Mraedis in https://github.com/immich-app/immich/pull/24062
* fix: show archived assets in favorite page by @bwees in https://github.com/immich-app/immich/pull/24052
* fix(mobile): first video memory on page doesn't play by @YarosMallorca in https://github.com/immich-app/immich/pull/23906
* feat(web): show detected faces in spherical photos by @meesfrensel in https://github.com/immich-app/immich/pull/23974
* fix: add users to album by @danieldietzler in https://github.com/immich-app/immich/pull/24133
* fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params by @lutostag in https://github.com/immich-app/immich/pull/23333
* fix: use proper updatedAt value in local assets by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24137
* fix: albums page reactivity loops by @danieldietzler in https://github.com/immich-app/immich/pull/24046
* fix: getAspectRatio fallback to db width and height by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24131
* fix(web): fix support & feedback modal wrapping by @Snowknight26 in https://github.com/immich-app/immich/pull/24018
* fix: don't get OCR data in shared link by @alextran1502 in https://github.com/immich-app/immich/pull/24152
* fix: duration extraction by @jrasm91 in https://github.com/immich-app/immich/pull/24178
* fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures by @LukaPrebil in https://github.com/immich-app/immich/pull/24045
* fix: update timeline-manager after archive actions by @midzelis in https://github.com/immich-app/immich/pull/24010
* fix: theme switcher by @jrasm91 in https://github.com/immich-app/immich/pull/24209
* fix: label 'for' attributes in user-api-key-grid by @kimsey0 in https://github.com/immich-app/immich/pull/24232
* fix(mobile): enable backup text overflows by @YarosMallorca in https://github.com/immich-app/immich/pull/24227
* fix(web): integrate zoom toggle button into panorama photo viewer by @meesfrensel in https://github.com/immich-app/immich/pull/24189
* fix(web): use full tag path when creating nested subtags by @NiklasvonM in https://github.com/immich-app/immich/pull/24249
* fix: only generate memory based on users assets by @alextran1502 in https://github.com/immich-app/immich/pull/24151
* fix(mobile): docs link by @mmomjian in https://github.com/immich-app/immich/pull/24277
* fix(server): use bigrams for cjk by @mertalev in https://github.com/immich-app/immich/pull/24285
* fix(ml): do not upscale preview by @mertalev in https://github.com/immich-app/immich/pull/24322
* fix(web): open onboarding documentation link in new tab by @carbonemys in https://github.com/immich-app/immich/pull/24289
* fix(mobile): use correct timezone displayed in the info sheet by @kao-byte in https://github.com/immich-app/immich/pull/24310
* fix(web): folder view sort oder by @etnoy in https://github.com/immich-app/immich/pull/24337
* fix(server): do not delete offline assets by @mertalev in https://github.com/immich-app/immich/pull/24355
* fix: exposure info and better readability by @alextran1502 in https://github.com/immich-app/immich/pull/24344
* fix: Adjust the zoom level by @jforseth210 in https://github.com/immich-app/immich/pull/24353
* fix: local full sync on Android on resume by @alextran1502 in https://github.com/immich-app/immich/pull/24348
* fix(web): Add minimum content size to logo for consistent visual on small screens by @kiloomar in https://github.com/immich-app/immich/pull/24372
* fix: use adjustment time in iOS for hash reset by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24047
* fix(server): update exiftool-vendored to v34 for more robust metadata extraction by @skatsubo in https://github.com/immich-app/immich/pull/24424
* fix(mobile): cannot create album while name field is focused by @YarosMallorca in https://github.com/immich-app/immich/pull/24449
* fix(web): [album table view] long album title overflows table row by @simonkub in https://github.com/immich-app/immich/pull/24450
* fix(mobile): fix overflow text in backup card by @YarosMallorca in https://github.com/immich-app/immich/pull/24448
* fix(mobile): timeline bottom padding on selection by @YarosMallorca in https://github.com/immich-app/immich/pull/24480
* feat(mobile): Localized backup upload details page by @ArnyminerZ in https://github.com/immich-app/immich/pull/21136
* fix(mobile): iOS local permission dialog extra whitespace by @kurtmckee in https://github.com/immich-app/immich/pull/24491
* fix(mobile): versionStatus.message text overflow by @idubnori in https://github.com/immich-app/immich/pull/24504
* fix(server): prevent metadata extraction failures on large video files by @hubert-taieb in https://github.com/immich-app/immich/pull/24094
* fix(web): show inferred timezone in date editor by @skatsubo in https://github.com/immich-app/immich/pull/24513
* fix(mobile): local videos with '#' don't play on android by @YarosMallorca in https://github.com/immich-app/immich/pull/24373
* fix: refresh appear in list after asset is added to a current or new album by @alextran1502 in https://github.com/immich-app/immich/pull/24523
* fix(mobile): birthday off by one day on remote by @YarosMallorca in https://github.com/immich-app/immich/pull/24527
* fix(web): download panel being hidden by admin sidebar by @diogotcorreia in https://github.com/immich-app/immich/pull/24583
* fix(web): recent search doesn't use search type by @YarosMallorca in https://github.com/immich-app/immich/pull/24578
* fix(server): only extract image's duration if format supports animation by @meesfrensel in https://github.com/immich-app/immich/pull/24587
* fix(mobile): local delete missing from sheet on some routes by @YarosMallorca in https://github.com/immich-app/immich/pull/24505
* fix(mobile): better UI for metadata panel by @kao-byte in https://github.com/immich-app/immich/pull/24428
* fix: shared link expiration and small styling by @alextran1502 in https://github.com/immich-app/immich/pull/24566
### 📚 Documentation
* docs: DB_STORAGE_TYPE is only used by the database container by @dionysius in https://github.com/immich-app/immich/pull/24215
* fix(docs): build `cli` for e2e tests by @roschaefer in https://github.com/immich-app/immich/pull/24184
* docs(faq): add more info on archiving by @etnoy in https://github.com/immich-app/immich/pull/24326
* fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT by @dionysius in https://github.com/immich-app/immich/pull/24335
* fix: prevent OOM on nginx reverse proxy servers by @NicholasFlamy in https://github.com/immich-app/immich/pull/24351
* fix(docs): obsolete docs about rootless docker by @roschaefer in https://github.com/immich-app/immich/pull/24376
* fix(docs): websockets in nginx example by @fourthwall in https://github.com/immich-app/immich/pull/24411
* fix(docs): slow upload speed with example nginx reverse proxy config by @goalie2002 in https://github.com/immich-app/immich/pull/24490
* fix(docs): typo in maintenance mode command by @bartvanvelden in https://github.com/immich-app/immich/pull/24518
### 🌐 Translations
* chore: add new language requests by @danieldietzler in https://github.com/immich-app/immich/pull/23991
## New Contributors
* @ujjwal123123 made their first contribution in https://github.com/immich-app/immich/pull/24101
* @lutostag made their first contribution in https://github.com/immich-app/immich/pull/23333
* @LukaPrebil made their first contribution in https://github.com/immich-app/immich/pull/24045
* @kimsey0 made their first contribution in https://github.com/immich-app/immich/pull/24232
* @SaphuA made their first contribution in https://github.com/immich-app/immich/pull/24240
* @dionysius made their first contribution in https://github.com/immich-app/immich/pull/24215
* @NiklasvonM made their first contribution in https://github.com/immich-app/immich/pull/24249
* @kao-byte made their first contribution in https://github.com/immich-app/immich/pull/24098
* @carbonemys made their first contribution in https://github.com/immich-app/immich/pull/24289
* @kiloomar made their first contribution in https://github.com/immich-app/immich/pull/24372
* @fourthwall made their first contribution in https://github.com/immich-app/immich/pull/24411
* @simonkub made their first contribution in https://github.com/immich-app/immich/pull/24450
* @ArnyminerZ made their first contribution in https://github.com/immich-app/immich/pull/21136
* @kurtmckee made their first contribution in https://github.com/immich-app/immich/pull/24491
* @hubert-taieb made their first contribution in https://github.com/immich-app/immich/pull/24094
* @bartvanvelden made their first contribution in https://github.com/immich-app/immich/pull/24518
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.3.1...v2.4.0
---

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.103", "version": "2.2.104",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.3",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",

View File

@@ -58,10 +58,6 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
ulimits:
nofile:
soft: 1048576
hard: 1048576
ports: ports:
- 9230:9230 - 9230:9230
- 9231:9231 - 9231:9231
@@ -100,10 +96,6 @@ services:
- app-node_modules:/usr/src/app/node_modules - app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit - sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage - coverage:/usr/src/app/web/coverage
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
immich-server: immich-server:
@@ -135,7 +127,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038 image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus

View File

@@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View File

@@ -24,6 +24,9 @@ server {
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause) # disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
proxy_request_buffering off; proxy_request_buffering off;
# increase body buffer to avoid limiting upload speed
client_body_buffer_size 1024k;
# Set headers # Set headers
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -32,8 +35,6 @@ server {
# enable websockets: http://nginx.org/en/docs/http/websocket.html # enable websockets: http://nginx.org/en/docs/http/websocket.html
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off; proxy_redirect off;
# set timeout # set timeout
@@ -43,6 +44,8 @@ server {
location / { location / {
proxy_pass http://<backend_url>:2283; proxy_pass http://<backend_url>:2283;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
} }
# useful when using Let's Encrypt http-01 challenge # useful when using Let's Encrypt http-01 challenge

View File

@@ -52,7 +52,7 @@ Password login has been enabled.
Disable Maintenance Mode Disable Maintenance Mode
``` ```
immich-admin disable-maintenace-mode immich-admin disable-maintenance-mode
Maintenance mode has been disabled. Maintenance mode has been disabled.
``` ```

View File

@@ -1,4 +1,8 @@
[ [
{
"label": "v2.4.0",
"url": "https://docs.v2.4.0.archive.immich.app"
},
{ {
"label": "v2.3.1", "label": "v2.3.1",
"url": "https://docs.v2.3.1.archive.immich.app" "url": "https://docs.v2.3.1.archive.immich.app"

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "2.3.1", "version": "2.4.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^24.10.1", "@types/node": "^24.10.3",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -36,7 +36,7 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0", "eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^33.0.0", "exiftool-vendored": "^34.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jose": "^5.6.3", "jose": "^5.6.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",

View File

@@ -5,7 +5,6 @@
"acknowledge": "Acknowledge", "acknowledge": "Acknowledge",
"action": "Action", "action": "Action",
"action_common_update": "Update", "action_common_update": "Update",
"action_description": "A set of action to perform on the filtered assets",
"actions": "Actions", "actions": "Actions",
"active": "Active", "active": "Active",
"active_count": "Active: {count}", "active_count": "Active: {count}",
@@ -16,13 +15,9 @@
"add_a_location": "Add a location", "add_a_location": "Add a location",
"add_a_name": "Add a name", "add_a_name": "Add a name",
"add_a_title": "Add a title", "add_a_title": "Add a title",
"add_action": "Add action",
"add_action_description": "Click to add an action to perform",
"add_birthday": "Add a birthday", "add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint", "add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern", "add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location", "add_location": "Add location",
"add_more_users": "Add more users", "add_more_users": "Add more users",
"add_partner": "Add partner", "add_partner": "Add partner",
@@ -41,7 +36,6 @@
"add_to_shared_album": "Add to shared album", "add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack", "add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL", "add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive", "added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites", "added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites", "added_to_favorites_count": "Added {count, number} to favorites",
@@ -473,7 +467,6 @@
"album_remove_user": "Remove user?", "album_remove_user": "Remove user?",
"album_remove_user_confirmation": "Are you sure you want to remove {user}?", "album_remove_user_confirmation": "Are you sure you want to remove {user}?",
"album_search_not_found": "No albums found matching your search", "album_search_not_found": "No albums found matching your search",
"album_selected": "Album selected",
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.", "album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
"album_summary": "Album summary", "album_summary": "Album summary",
"album_updated": "Album updated", "album_updated": "Album updated",
@@ -495,7 +488,6 @@
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.", "albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
"albums_feature_description": "Collections of assets that can be shared with other users.", "albums_feature_description": "Collections of assets that can be shared with other users.",
"albums_on_device_count": "Albums on device ({count})", "albums_on_device_count": "Albums on device ({count})",
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
"all": "All", "all": "All",
"all_albums": "All albums", "all_albums": "All albums",
"all_people": "All people", "all_people": "All people",
@@ -532,12 +524,10 @@
"archived_count": "{count, plural, other {Archived #}}", "archived_count": "{count, plural, other {Archived #}}",
"are_these_the_same_person": "Are these the same person?", "are_these_the_same_person": "Are these the same person?",
"are_you_sure_to_do_this": "Are you sure you want to do this?", "are_you_sure_to_do_this": "Are you sure you want to do this?",
"array_field_not_fully_supported": "Array fields require manual JSON editing",
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
"asset_added_to_album": "Added to album", "asset_added_to_album": "Added to album",
"asset_adding_to_album": "Adding to album…", "asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_description_updated": "Asset description has been updated", "asset_description_updated": "Asset description has been updated",
"asset_filename_is_offline": "Asset {filename} is offline", "asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces", "asset_has_unassigned_faces": "Asset has unassigned faces",
@@ -662,6 +652,7 @@
"backup_options_page_title": "Backup options", "backup_options_page_title": "Backup options",
"backup_setting_subtitle": "Manage background and foreground upload settings", "backup_setting_subtitle": "Manage background and foreground upload settings",
"backup_settings_subtitle": "Manage upload settings", "backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward", "backward": "Backward",
"biometric_auth_enabled": "Biometric authentication enabled", "biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication", "biometric_locked_out": "You are locked out of biometric authentication",
@@ -720,8 +711,6 @@
"change_password_form_password_mismatch": "Passwords do not match", "change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password", "change_password_form_reenter_new_password": "Re-enter New Password",
"change_pin_code": "Change PIN code", "change_pin_code": "Change PIN code",
"change_trigger": "Change trigger",
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
"change_your_password": "Change your password", "change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully", "changed_visibility_successfully": "Changed visibility successfully",
"charging": "Charging", "charging": "Charging",
@@ -730,6 +719,7 @@
"check_corrupt_asset_backup_button": "Perform check", "check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "Check Logs", "check_logs": "Check Logs",
"checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge", "choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City", "city": "City",
"clear": "Clear", "clear": "Clear",
@@ -797,7 +787,6 @@
"create_album": "Create album", "create_album": "Create album",
"create_album_page_untitled": "Untitled", "create_album_page_untitled": "Untitled",
"create_api_key": "Create API key", "create_api_key": "Create API key",
"create_first_workflow": "Create first workflow",
"create_library": "Create Library", "create_library": "Create Library",
"create_link": "Create link", "create_link": "Create link",
"create_link_to_share": "Create link to share", "create_link_to_share": "Create link to share",
@@ -812,7 +801,6 @@
"create_tag": "Create tag", "create_tag": "Create tag",
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user", "create_user": "Create user",
"create_workflow": "Create workflow",
"created": "Created", "created": "Created",
"created_at": "Created", "created_at": "Created",
"creating_linked_albums": "Creating linked albums...", "creating_linked_albums": "Creating linked albums...",
@@ -879,7 +867,6 @@
"deselect_all": "Deselect All", "deselect_all": "Deselect All",
"details": "Details", "details": "Details",
"direction": "Direction", "direction": "Direction",
"disable": "Disable",
"disabled": "Disabled", "disabled": "Disabled",
"disallow_edits": "Disallow edits", "disallow_edits": "Disallow edits",
"discord": "Discord", "discord": "Discord",
@@ -942,13 +929,11 @@
"edit_tag": "Edit tag", "edit_tag": "Edit tag",
"edit_title": "Edit Title", "edit_title": "Edit Title",
"edit_user": "Edit user", "edit_user": "Edit user",
"edit_workflow": "Edit workflow",
"editor": "Editor", "editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?", "editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios", "editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_rotation": "Rotation", "editor_crop_tool_h2_rotation": "Rotation",
"editor_mode": "Editor mode",
"email": "Email", "email": "Email",
"email_notifications": "Email notifications", "email_notifications": "Email notifications",
"empty_folder": "This folder is empty", "empty_folder": "This folder is empty",
@@ -1029,7 +1014,6 @@
"unable_to_complete_oauth_login": "Unable to complete OAuth login", "unable_to_complete_oauth_login": "Unable to complete OAuth login",
"unable_to_connect": "Unable to connect", "unable_to_connect": "Unable to connect",
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https", "unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
"unable_to_create": "Unable to create workflow",
"unable_to_create_admin_account": "Unable to create admin account", "unable_to_create_admin_account": "Unable to create admin account",
"unable_to_create_api_key": "Unable to create a new API Key", "unable_to_create_api_key": "Unable to create a new API Key",
"unable_to_create_library": "Unable to create library", "unable_to_create_library": "Unable to create library",
@@ -1040,7 +1024,6 @@
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern", "unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_shared_link": "Unable to delete shared link", "unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user", "unable_to_delete_user": "Unable to delete user",
"unable_to_delete_workflow": "Unable to delete workflow",
"unable_to_download_files": "Unable to download files", "unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern", "unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
"unable_to_empty_trash": "Unable to empty trash", "unable_to_empty_trash": "Unable to empty trash",
@@ -1091,7 +1074,6 @@
"unable_to_update_settings": "Unable to update settings", "unable_to_update_settings": "Unable to update settings",
"unable_to_update_timeline_display_status": "Unable to update timeline display status", "unable_to_update_timeline_display_status": "Unable to update timeline display status",
"unable_to_update_user": "Unable to update user", "unable_to_update_user": "Unable to update user",
"unable_to_update_workflow": "Unable to update workflow",
"unable_to_upload_file": "Unable to upload file" "unable_to_upload_file": "Unable to upload file"
}, },
"exclusion_pattern": "Exclusion pattern", "exclusion_pattern": "Exclusion pattern",
@@ -1144,10 +1126,8 @@
"filename": "Filename", "filename": "Filename",
"filetype": "Filetype", "filetype": "Filetype",
"filter": "Filter", "filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_people": "Filter people", "filter_people": "Filter people",
"filter_places": "Filter places", "filter_places": "Filter places",
"filters": "Filters",
"find_them_fast": "Find them fast by name with search", "find_them_fast": "Find them fast by name with search",
"first": "First", "first": "First",
"fix_incorrect_match": "Fix incorrect match", "fix_incorrect_match": "Fix incorrect match",
@@ -1163,7 +1143,6 @@
"general": "General", "general": "General",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map", "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"get_help": "Get Help", "get_help": "Get Help",
"get_people_error": "Error getting people",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started", "getting_started": "Getting Started",
"go_back": "Go back", "go_back": "Go back",
@@ -1189,13 +1168,13 @@
"header_settings_header_name_input": "Header name", "header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value", "header_settings_header_value_input": "Header value",
"headers_settings_tile_title": "Custom proxy headers", "headers_settings_tile_title": "Custom proxy headers",
"height": "Height",
"hi_user": "Hi {name} ({email})", "hi_user": "Hi {name} ({email})",
"hide_all_people": "Hide all people", "hide_all_people": "Hide all people",
"hide_gallery": "Hide gallery", "hide_gallery": "Hide gallery",
"hide_named_person": "Hide person {name}", "hide_named_person": "Hide person {name}",
"hide_password": "Hide password", "hide_password": "Hide password",
"hide_person": "Hide person", "hide_person": "Hide person",
"hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition", "hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people", "hide_unnamed_people": "Hide unnamed people",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
@@ -1268,8 +1247,6 @@
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}", "ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"items_count": "{count, plural, one {# item} other {# items}}", "items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs", "jobs": "Jobs",
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep", "keep": "Keep",
"keep_all": "Keep All", "keep_all": "Keep All",
"keep_this_delete_others": "Keep this, delete others", "keep_this_delete_others": "Keep this, delete others",
@@ -1314,6 +1291,7 @@
"local": "Local", "local": "Local",
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server", "local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_assets": "Local Assets", "local_assets": "Local Assets",
"local_id": "Local ID",
"local_media_summary": "Local Media Summary", "local_media_summary": "Local Media Summary",
"local_network": "Local network", "local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
@@ -1438,13 +1416,11 @@
"monthly_title_text_date_format": "MMMM y", "monthly_title_text_date_format": "MMMM y",
"more": "More", "more": "More",
"move": "Move", "move": "Move",
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder", "move_off_locked_folder": "Move out of locked folder",
"move_to": "Move to", "move_to": "Move to",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder", "move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"move_up": "Move up",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
"moved_to_trash": "Moved to trash", "moved_to_trash": "Moved to trash",
@@ -1454,7 +1430,6 @@
"my_albums": "My albums", "my_albums": "My albums",
"name": "Name", "name": "Name",
"name_or_nickname": "Name or nickname", "name_or_nickname": "Name or nickname",
"name_required": "Name is required",
"navigate": "Navigate", "navigate": "Navigate",
"navigate_to_time": "Navigate to Time", "navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_photos_upload": "Use cellular data to backup photos",
@@ -1479,7 +1454,6 @@
"next": "Next", "next": "Next",
"next_memory": "Next memory", "next_memory": "Next memory",
"no": "No", "no": "No",
"no_actions_added": "No actions added yet",
"no_albums_message": "Create an album to organize your photos and videos", "no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.", "no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.", "no_albums_yet": "It looks like you do not have any albums yet.",
@@ -1489,13 +1463,11 @@
"no_cast_devices_found": "No cast devices found", "no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets", "no_checksum_local": "No checksum available - cannot fetch local assets",
"no_checksum_remote": "No checksum available - cannot fetch remote asset", "no_checksum_remote": "No checksum available - cannot fetch remote asset",
"no_configuration_needed": "No configuration needed",
"no_devices": "No authorized devices", "no_devices": "No authorized devices",
"no_duplicates_found": "No duplicates were found.", "no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available", "no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.", "no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos", "no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos", "no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum", "no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set", "no_location_set": "No location set",
@@ -1591,7 +1563,6 @@
"people": "People", "people": "People",
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}", "people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
"people_feature_description": "Browsing photos and videos grouped by people", "people_feature_description": "Browsing photos and videos grouped by people",
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
"people_sidebar_description": "Display a link to People in the sidebar", "people_sidebar_description": "Display a link to People in the sidebar",
"permanent_deletion_warning": "Permanent deletion warning", "permanent_deletion_warning": "Permanent deletion warning",
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
@@ -1616,8 +1587,6 @@
"person_age_years": "{years, plural, other {# years}} old", "person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}", "person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",
"person_selected": "Person selected",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.", "photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
"photos": "Photos", "photos": "Photos",
"photos_and_videos": "Photos & Videos", "photos_and_videos": "Photos & Videos",
@@ -1867,22 +1836,17 @@
"second": "Second", "second": "Second",
"see_all_people": "See all people", "see_all_people": "See all people",
"select": "Select", "select": "Select",
"select_album": "Select album",
"select_album_cover": "Select album cover", "select_album_cover": "Select album cover",
"select_albums": "Select albums",
"select_all": "Select all", "select_all": "Select all",
"select_all_duplicates": "Select all duplicates", "select_all_duplicates": "Select all duplicates",
"select_all_in": "Select all in {group}", "select_all_in": "Select all in {group}",
"select_avatar_color": "Select avatar color", "select_avatar_color": "Select avatar color",
"select_count": "{count, plural, one {Select #} other {Select #}}",
"select_face": "Select face", "select_face": "Select face",
"select_featured_photo": "Select featured photo", "select_featured_photo": "Select featured photo",
"select_from_computer": "Select from computer", "select_from_computer": "Select from computer",
"select_keep_all": "Select keep all", "select_keep_all": "Select keep all",
"select_library_owner": "Select library owner", "select_library_owner": "Select library owner",
"select_new_face": "Select new face", "select_new_face": "Select new face",
"select_people": "Select people",
"select_person": "Select person",
"select_person_to_tag": "Select a person to tag", "select_person_to_tag": "Select a person to tag",
"select_photos": "Select photos", "select_photos": "Select photos",
"select_trash_all": "Select trash all", "select_trash_all": "Select trash all",
@@ -2018,7 +1982,6 @@
"show_password": "Show password", "show_password": "Show password",
"show_person_options": "Show person options", "show_person_options": "Show person options",
"show_progress_bar": "Show Progress Bar", "show_progress_bar": "Show Progress Bar",
"show_schema": "Show schema",
"show_search_options": "Show search options", "show_search_options": "Show search options",
"show_shared_links": "Show shared links", "show_shared_links": "Show shared links",
"show_slideshow_transition": "Show slideshow transition", "show_slideshow_transition": "Show slideshow transition",
@@ -2146,13 +2109,6 @@
"trash_page_select_assets_btn": "Select assets", "trash_page_select_assets_btn": "Select assets",
"trash_page_title": "Trash ({count})", "trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_asset_uploaded": "Asset Uploaded",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kick off the workflow",
"trigger_person_recognized": "Person Recognized",
"trigger_person_recognized_description": "Triggered when a person is detected",
"trigger_type": "Trigger type",
"troubleshoot": "Troubleshoot", "troubleshoot": "Troubleshoot",
"type": "Type", "type": "Type",
"unable_to_change_pin_code": "Unable to change PIN code", "unable_to_change_pin_code": "Unable to change PIN code",
@@ -2183,9 +2139,7 @@
"unstack": "Un-stack", "unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked", "unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type",
"untagged": "Untagged", "untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next", "up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:", "update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated", "updated_at": "Updated",
@@ -2231,7 +2185,6 @@
"utilities": "Utilities", "utilities": "Utilities",
"validate": "Validate", "validate": "Validate",
"validate_endpoint_error": "Please enter a valid URL", "validate_endpoint_error": "Please enter a valid URL",
"validation_error": "Validation error",
"variables": "Variables", "variables": "Variables",
"version": "Version", "version": "Version",
"version_announcement_closing": "Your friend, Alex", "version_announcement_closing": "Your friend, Alex",
@@ -2263,28 +2216,15 @@
"viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack", "viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"visual": "Visual",
"visual_builder": "Visual builder",
"waiting": "Waiting", "waiting": "Waiting",
"waiting_count": "Waiting: {count}", "waiting_count": "Waiting: {count}",
"warning": "Warning", "warning": "Warning",
"week": "Week", "week": "Week",
"welcome": "Welcome", "welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich", "welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name", "wifi_name": "Wi-Fi Name",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?", "workflow": "Workflow",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",
"workflow_info": "Workflow info",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
"workflow_update_success": "Workflow updated successfully",
"workflow_updated": "Workflow updated",
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"wrong_pin_code": "Wrong PIN code", "wrong_pin_code": "Wrong PIN code",
"year": "Year", "year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago", "years_ago": "{years, plural, one {# year} other {# years}} ago",

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "immich-ml" name = "immich-ml"
version = "2.3.1" version = "2.4.0"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0" requires-python = ">=3.10,<4.0"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3028, "android.injected.version.code" => 3029,
"android.injected.version.name" => "2.3.1", "android.injected.version.name" => "2.4.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') 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

@@ -137,8 +137,7 @@
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string> <string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and <string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string> <string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key> <key>NSLocationUsageDescription</key>

View File

@@ -98,7 +98,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
), ),
), ),
Text( Text(
'Tap for more details', "backup_upload_details_page_more_details".t(context: context),
style: context.textTheme.bodySmall?.copyWith( style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6), color: context.colorScheme.onSurface.withValues(alpha: 0.6),
), ),
@@ -239,14 +239,20 @@ class FileDetailDialog extends ConsumerWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
if (asset != null) ...[ if (asset != null) ...[
_buildInfoSection(context, [ _buildInfoSection(context, [
_buildInfoRow(context, "Filename", path.basename(uploadStatus.filename)), _buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
_buildInfoRow(context, "Local ID", asset.id), _buildInfoRow(context, "local_id".t(context: context), asset.id),
_buildInfoRow(context, "File Size", formatHumanReadableBytes(uploadStatus.fileSize, 2)), _buildInfoRow(
if (asset.width != null) _buildInfoRow(context, "Width", "${asset.width}px"), context,
if (asset.height != null) _buildInfoRow(context, "Height", "${asset.height}px"), "file_size".t(context: context),
_buildInfoRow(context, "Created At", asset.createdAt.toString()), formatHumanReadableBytes(uploadStatus.fileSize, 2),
_buildInfoRow(context, "Updated At", asset.updatedAt.toString()), ),
if (asset.checksum != null) _buildInfoRow(context, "Checksum", asset.checksum!), if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"),
if (asset.height != null)
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
if (asset.checksum != null)
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
]), ]),
], ],
], ],

View File

@@ -1,173 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
final _features = [
_Feature(
name: 'Main Timeline',
icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
),
_Feature(
name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded,
onTap: (ctx, ref) async {
final user = ref.watch(currentUserProvider);
if (user == null) {
return Future.value();
}
final assets = await ref.read(remoteAssetRepositoryProvider).getSome(user.id);
final selectedAssets = await ctx.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: assets.toSet()),
);
Logger("FeaturesInDevelopment").fine("Selected ${selectedAssets?.length ?? 0} assets");
return Future.value();
},
),
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
_Feature(
name: 'Sync Local',
icon: Icons.photo_album_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(),
),
_Feature(
name: 'Sync Local Full (1)',
icon: Icons.photo_library_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
),
_Feature(
name: 'Hash Local Assets (2)',
icon: Icons.numbers_outlined,
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
),
_Feature(
name: 'Sync Remote (3)',
icon: Icons.refresh_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(),
),
_Feature(
name: 'WAL Checkpoint',
icon: Icons.save_rounded,
onTap: (_, ref) => ref.read(driftProvider).customStatement("pragma wal_checkpoint(truncate)"),
),
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
_Feature(
name: 'Clear Delta Checkpoint',
icon: Icons.delete_rounded,
onTap: (_, ref) => ref.read(nativeSyncApiProvider).clearSyncCheckpoint(),
),
_Feature(
name: 'Clear Local Data',
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
icon: Icons.delete_forever_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
await db.localAssetEntity.deleteAll();
await db.localAlbumEntity.deleteAll();
await db.localAlbumAssetEntity.deleteAll();
},
),
_Feature(
name: 'Clear Remote Data',
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
icon: Icons.delete_sweep_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
await db.remoteAssetEntity.deleteAll();
await db.remoteExifEntity.deleteAll();
await db.remoteAlbumEntity.deleteAll();
await db.remoteAlbumUserEntity.deleteAll();
await db.remoteAlbumAssetEntity.deleteAll();
await db.memoryEntity.deleteAll();
await db.memoryAssetEntity.deleteAll();
await db.stackEntity.deleteAll();
await db.personEntity.deleteAll();
await db.assetFaceEntity.deleteAll();
},
),
_Feature(
name: 'Local Media Summary',
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
icon: Icons.table_chart_rounded,
onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
),
_Feature(
name: 'Remote Media Summary',
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
icon: Icons.summarize_rounded,
onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
),
_Feature(
name: 'Reset Sqlite',
icon: Icons.table_view_rounded,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
onTap: (_, ref) async {
final drift = ref.read(driftProvider);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
final migrator = drift.createMigrator();
for (final entity in drift.allSchemaEntities) {
await migrator.drop(entity);
await migrator.create(entity);
}
},
),
];
@RoutePage()
class FeatInDevPage extends StatelessWidget {
const FeatInDevPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('features_in_development'.tr()), centerTitle: true),
body: Column(
children: [
Flexible(
flex: 1,
child: ListView.builder(
itemBuilder: (_, index) {
final feat = _features[index];
return Consumer(
builder: (ctx, ref, _) => ListTile(
title: Text(feat.name, style: feat.style),
trailing: Icon(feat.icon),
visualDensity: VisualDensity.compact,
onTap: () => unawaited(feat.onTap(ctx, ref)),
),
);
},
itemCount: _features.length,
),
),
const Divider(height: 0),
],
),
);
}
}
class _Feature {
const _Feature({required this.name, required this.icon, required this.onTap, this.style});
final String name;
final IconData icon;
final TextStyle? style;
final Future<void> Function(BuildContext, WidgetRef _) onTap;
}

View File

@@ -0,0 +1,51 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_ui/immich_ui.dart';
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
final children = <Widget>[];
final items = [
(variant: ImmichVariant.filled, title: "Filled Variant"),
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
];
for (final (:variant, :title) in items) {
children.add(Text(title));
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
}
return children;
}
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
const ImmichUIShowcasePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Immich UI Showcase')),
body: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
spacing: 10,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("IconButton", style: context.textTheme.titleLarge),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
),
Text("CloseButton", style: context.textTheme.titleLarge),
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
],
),
),
),
);
}
}

View File

@@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(album.name), title: Text(album.name),
actions: [const LikeActivityActionButton(menuItem: true)], actions: [const LikeActivityActionButton(iconOnly: true)],
actionsPadding: const EdgeInsets.only(right: 8), actionsPadding: const EdgeInsets.only(right: 8),
), ),
body: activities.widgetWhen( body: activities.widgetWhen(

View File

@@ -27,8 +27,19 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
bool isAlbumTitleTextFieldFocus = false; bool isAlbumTitleTextFieldFocus = false;
Set<BaseAsset> selectedAssets = {}; Set<BaseAsset> selectedAssets = {};
@override
void initState() {
super.initState();
albumTitleController.addListener(_onTitleChanged);
}
void _onTitleChanged() {
setState(() {});
}
@override @override
void dispose() { void dispose() {
albumTitleController.removeListener(_onTitleChanged);
albumTitleController.dispose(); albumTitleController.dispose();
albumDescriptionController.dispose(); albumDescriptionController.dispose();
albumTitleTextFieldFocusNode.dispose(); albumTitleTextFieldFocusNode.dispose();

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_ui/immich_ui.dart';
/// A widget for cropping an image. /// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows /// This widget uses [HookWidget] to manage its lifecycle and state. It allows
@@ -30,11 +31,13 @@ class DriftCropImagePage extends HookWidget {
appBar: AppBar( appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor, backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()), title: Text("crop".tr()),
leading: CloseButton(color: context.primaryColor), leading: const ImmichCloseButton(),
actions: [ actions: [
IconButton( ImmichIconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24), icon: Icons.done_rounded,
onPressed: () async { color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onTap: () async {
final croppedImage = await cropController.croppedImage(); final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true))); unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
}, },
@@ -72,17 +75,17 @@ class DriftCropImagePage extends HookWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
IconButton( ImmichIconButton(
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color), icon: Icons.rotate_left,
onPressed: () { variant: ImmichVariant.ghost,
cropController.rotateLeft(); color: ImmichColor.secondary,
}, onTap: () => cropController.rotateLeft(),
), ),
IconButton( ImmichIconButton(
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color), icon: Icons.rotate_right,
onPressed: () { variant: ImmichVariant.ghost,
cropController.rotateRight(); color: ImmichColor.secondary,
}, onTap: () => cropController.rotateRight(),
), ),
], ],
), ),

View File

@@ -21,12 +21,36 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
enum AddToMenuItem { album, archive, unarchive, lockedFolder } enum AddToMenuItem { album, archive, unarchive, lockedFolder }
class AddActionButton extends ConsumerWidget { class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key}); const AddActionButton({super.key, this.originalTheme});
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async { final ThemeData? originalTheme;
@override
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
}
class _AddActionButtonState extends ConsumerState<AddActionButton> {
void _handleMenuSelection(AddToMenuItem selected) {
switch (selected) {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
List<Widget> _buildMenuChildren() {
final asset = ref.read(currentAssetNotifier); final asset = ref.read(currentAssetNotifier);
if (asset == null) return; if (asset == null) return [];
final user = ref.read(currentUserProvider); final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
@@ -35,84 +59,50 @@ class AddActionButton extends ConsumerWidget {
final hasRemote = asset is RemoteAsset; final hasRemote = asset is RemoteAsset;
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived; final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived; final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
final menuItemHeight = 30.0;
final List<PopupMenuEntry<AddToMenuItem>> items = [ return [
PopupMenuItem( Padding(
enabled: false, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
textStyle: context.textTheme.labelMedium, child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
height: 40,
child: Text("add_to_bottom_bar".tr()),
), ),
PopupMenuItem( BaseActionButton(
height: menuItemHeight, iconData: Icons.photo_album_outlined,
value: AddToMenuItem.album, label: "album".tr(),
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())), menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
), ),
const PopupMenuDivider(),
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
if (isOwner) ...[ if (isOwner) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
if (showArchive) if (showArchive)
PopupMenuItem( BaseActionButton(
height: menuItemHeight, iconData: Icons.archive_outlined,
value: AddToMenuItem.archive, label: "archive".tr(),
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())), menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
), ),
if (showUnarchive) if (showUnarchive)
PopupMenuItem( BaseActionButton(
height: menuItemHeight, iconData: Icons.unarchive_outlined,
value: AddToMenuItem.unarchive, label: "unarchive".tr(),
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())), menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
), ),
PopupMenuItem( BaseActionButton(
height: menuItemHeight, iconData: Icons.lock_outline,
value: AddToMenuItem.lockedFolder, label: "locked_folder".tr(),
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())), menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
), ),
], ],
]; ];
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
context: context,
color: context.themeData.scaffoldBackgroundColor,
position: _menuPosition(context),
items: items,
popUpAnimationStyle: AnimationStyle.noAnimation,
);
if (selected == null) {
return;
} }
switch (selected) { void _openAlbumSelector() {
case AddToMenuItem.album:
_openAlbumSelector(context, ref);
break;
case AddToMenuItem.archive:
await performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
RelativeRect _menuPosition(BuildContext context) {
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox) {
return RelativeRect.fill;
}
final size = renderObject.size;
final position = renderObject.localToGlobal(Offset.zero);
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
}
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
final currentAsset = ref.read(currentAssetNotifier); final currentAsset = ref.read(currentAssetNotifier);
if (currentAsset == null) { if (currentAsset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
@@ -120,7 +110,8 @@ class AddActionButton extends ConsumerWidget {
} }
final List<Widget> slivers = [ final List<Widget> slivers = [
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)), const CreateAlbumButton(),
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album)),
]; ];
showModalBottomSheet( showModalBottomSheet(
@@ -141,7 +132,7 @@ class AddActionButton extends ConsumerWidget {
); );
} }
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async { Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
final latest = ref.read(currentAssetNotifier); final latest = ref.read(currentAssetNotifier);
if (latest == null) { if (latest == null) {
@@ -165,6 +156,9 @@ class AddActionButton extends ConsumerWidget {
context: context, context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
); );
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
} }
if (!context.mounted) { if (!context.mounted) {
@@ -174,17 +168,38 @@ class AddActionButton extends ConsumerWidget {
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(currentAssetNotifier);
if (asset == null) { if (asset == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return Builder(
builder: (buttonContext) { final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: widget.originalTheme != null
? [
Theme(
data: widget.originalTheme!,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
),
]
: _buildMenuChildren(),
builder: (context, controller, child) {
return BaseActionButton( return BaseActionButton(
iconData: Icons.add, iconData: Icons.add,
label: "add_to_bottom_bar".tr(), label: "add_to_bottom_bar".tr(),
onPressed: () => _showAddOptions(buttonContext, ref), onPressed: () => controller.isOpen ? controller.close() : controller.open(),
); );
}, },
); );

View File

@@ -9,8 +9,10 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
class AdvancedInfoActionButton extends ConsumerWidget { class AdvancedInfoActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const AdvancedInfoActionButton({super.key, required this.source}); const AdvancedInfoActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -26,6 +28,8 @@ class AdvancedInfoActionButton extends ConsumerWidget {
maxWidth: 115.0, maxWidth: 115.0,
iconData: Icons.help_outline_rounded, iconData: Icons.help_outline_rounded,
label: "troubleshoot".t(context: context), label: "troubleshoot".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -35,8 +35,10 @@ Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required
class ArchiveActionButton extends ConsumerWidget { class ArchiveActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ArchiveActionButton({super.key, required this.source}); const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async { Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performArchiveAction(context, ref, source: source); await performArchiveAction(context, ref, source: source);
@@ -47,6 +49,8 @@ class ArchiveActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.archive_outlined, iconData: Icons.archive_outlined,
label: "to_archive".t(context: context), label: "to_archive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.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/extensions/build_context_extensions.dart';
class BaseActionButton extends StatelessWidget { class BaseActionButton extends ConsumerWidget {
const BaseActionButton({ const BaseActionButton({
super.key, super.key,
required this.label, required this.label,
@@ -11,6 +12,7 @@ class BaseActionButton extends StatelessWidget {
this.onLongPressed, this.onLongPressed,
this.maxWidth = 90.0, this.maxWidth = 90.0,
this.minWidth, this.minWidth,
this.iconOnly = false,
this.menuItem = false, this.menuItem = false,
}); });
@@ -19,25 +21,42 @@ class BaseActionButton extends StatelessWidget {
final Color? iconColor; final Color? iconColor;
final double maxWidth; final double maxWidth;
final double? minWidth; final double? minWidth;
/// When true, renders only an IconButton without text label
final bool iconOnly;
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
final bool menuItem; final bool menuItem;
final void Function()? onPressed; final void Function()? onPressed;
final void Function()? onLongPressed; final void Function()? onLongPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context); final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0; final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color; final textColor = context.themeData.textTheme.labelLarge?.color;
if (menuItem) { if (iconOnly) {
return IconButton( return IconButton(
onPressed: onPressed, onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor), icon: Icon(iconData, size: iconSize, color: iconColor),
); );
} }
if (menuItem) {
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
);
}
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth), constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton( child: MaterialButton(

View File

@@ -7,8 +7,9 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget { class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.menuItem = true}); const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem; final bool menuItem;
@override @override
@@ -22,6 +23,7 @@ class CastActionButton extends ConsumerWidget {
onPressed: () { onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog()); showDialog(context: context, builder: (context) => const CastDialog());
}, },
iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
); );
} }

View File

@@ -18,7 +18,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DeleteActionButton extends ConsumerWidget { class DeleteActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool showConfirmation; final bool showConfirmation;
const DeleteActionButton({super.key, required this.source, this.showConfirmation = false}); final bool iconOnly;
final bool menuItem;
const DeleteActionButton({
super.key,
required this.source,
this.showConfirmation = false,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -74,6 +82,8 @@ class DeleteActionButton extends ConsumerWidget {
maxWidth: 110.0, maxWidth: 110.0,
iconData: Icons.delete_sweep_outlined, iconData: Icons.delete_sweep_outlined,
label: "delete".t(context: context), label: "delete".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -14,8 +14,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// - Prompt to delete the asset locally /// - Prompt to delete the asset locally
class DeleteLocalActionButton extends ConsumerWidget { class DeleteLocalActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DeleteLocalActionButton({super.key, required this.source}); const DeleteLocalActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -55,6 +57,8 @@ class DeleteLocalActionButton extends ConsumerWidget {
maxWidth: 95.0, maxWidth: 95.0,
iconData: Icons.no_cell_outlined, iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".t(context: context), label: "control_bottom_app_bar_delete_from_local".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -15,8 +15,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// - Prompt to delete the asset locally /// - Prompt to delete the asset locally
class DeletePermanentActionButton extends ConsumerWidget { class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DeletePermanentActionButton({super.key, required this.source}); const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -51,6 +53,8 @@ class DeletePermanentActionButton extends ConsumerWidget {
maxWidth: 110.0, maxWidth: 110.0,
iconData: Icons.delete_forever, iconData: Icons.delete_forever,
label: "delete_permanently".t(context: context), label: "delete_permanently".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -10,8 +10,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class DownloadActionButton extends ConsumerWidget { class DownloadActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem; final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.menuItem = false}); const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async { void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
if (!context.mounted) { if (!context.mounted) {
@@ -38,6 +39,7 @@ class DownloadActionButton extends ConsumerWidget {
iconData: Icons.download, iconData: Icons.download,
maxWidth: 95, maxWidth: 95,
label: "download".t(context: context), label: "download".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager), onPressed: () => _onTap(context, ref, backgroundManager),
); );

View File

@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteActionButton extends ConsumerWidget { class FavoriteActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem; final bool menuItem;
const FavoriteActionButton({super.key, required this.source, this.menuItem = false}); const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -44,6 +45,7 @@ class FavoriteActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.favorite_border_rounded, iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context), label: "favorite".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );

View File

@@ -12,8 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
class LikeActivityActionButton extends ConsumerWidget { class LikeActivityActionButton extends ConsumerWidget {
const LikeActivityActionButton({super.key, this.menuItem = false}); const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem; final bool menuItem;
@override @override
@@ -46,17 +47,19 @@ class LikeActivityActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
maxWidth: 60, maxWidth: 60,
iconData: liked != null ? Icons.favorite : Icons.favorite_border, iconData: liked != null ? Icons.thumb_up : Icons.thumb_up_off_alt,
label: "like".t(context: context), label: "like".t(context: context),
onPressed: () => onTap(liked), onPressed: () => onTap(liked),
iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
); );
}, },
// default to empty heart during loading // default to empty heart during loading
loading: () => BaseActionButton( loading: () => BaseActionButton(
iconData: Icons.favorite_border, iconData: Icons.thumb_up_off_alt,
label: "like".t(context: context), label: "like".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
), ),
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])), error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),

View File

@@ -5,8 +5,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoActionButton extends ConsumerWidget { class MotionPhotoActionButton extends ConsumerWidget {
const MotionPhotoActionButton({super.key, this.menuItem = true}); const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem; final bool menuItem;
@override @override
@@ -17,6 +18,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded, iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
label: "play_motion_photo".t(context: context), label: "play_motion_photo".t(context: context),
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
); );
} }

View File

@@ -38,8 +38,10 @@ Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref,
class MoveToLockFolderActionButton extends ConsumerWidget { class MoveToLockFolderActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const MoveToLockFolderActionButton({super.key, required this.source}); const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async { Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performMoveToLockFolderAction(context, ref, source: source); await performMoveToLockFolderAction(context, ref, source: source);
@@ -51,6 +53,8 @@ class MoveToLockFolderActionButton extends ConsumerWidget {
maxWidth: 115.0, maxWidth: 115.0,
iconData: Icons.lock_outline_rounded, iconData: Icons.lock_outline_rounded,
label: "move_to_locked_folder".t(context: context), label: "move_to_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -11,8 +13,16 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoveFromAlbumActionButton extends ConsumerWidget { class RemoveFromAlbumActionButton extends ConsumerWidget {
final String albumId; final String albumId;
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromAlbumActionButton({super.key, required this.albumId, required this.source}); const RemoveFromAlbumActionButton({
super.key,
required this.albumId,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -22,6 +32,10 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId); final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'remove_from_album_action_prompt'.t( final successMessage = 'remove_from_album_action_prompt'.t(
context: context, context: context,
args: {'count': result.count.toString()}, args: {'count': result.count.toString()},
@@ -42,6 +56,8 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.remove_circle_outline, iconData: Icons.remove_circle_outline,
label: "remove_from_album".t(context: context), label: "remove_from_album".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
maxWidth: 100, maxWidth: 100,
); );

View File

@@ -10,8 +10,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoveFromLockFolderActionButton extends ConsumerWidget { class RemoveFromLockFolderActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromLockFolderActionButton({super.key, required this.source}); const RemoveFromLockFolderActionButton({
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -42,6 +49,8 @@ class RemoveFromLockFolderActionButton extends ConsumerWidget {
maxWidth: 100.0, maxWidth: 100.0,
iconData: Icons.lock_open_rounded, iconData: Icons.lock_open_rounded,
label: "remove_from_locked_folder".t(context: context), label: "remove_from_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -31,8 +31,10 @@ class _SharePreparingDialog extends StatelessWidget {
class ShareActionButton extends ConsumerWidget { class ShareActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ShareActionButton({super.key, required this.source}); const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -74,6 +76,8 @@ class ShareActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: 'share'.t(context: context), label: 'share'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -7,8 +7,10 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
class ShareLinkActionButton extends ConsumerWidget { class ShareLinkActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ShareLinkActionButton({super.key, required this.source}); const ShareLinkActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
_onTap(BuildContext context, WidgetRef ref) async { _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -23,6 +25,8 @@ class ShareLinkActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.link_rounded, iconData: Icons.link_rounded,
label: "share_link".t(context: context), label: "share_link".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -13,8 +13,10 @@ import 'package:immich_mobile/routing/router.dart';
class SimilarPhotosActionButton extends ConsumerWidget { class SimilarPhotosActionButton extends ConsumerWidget {
final String assetId; final String assetId;
final bool iconOnly;
final bool menuItem;
const SimilarPhotosActionButton({super.key, required this.assetId}); const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -44,6 +46,8 @@ class SimilarPhotosActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.compare, iconData: Icons.compare,
label: "view_similar_photos".t(context: context), label: "view_similar_photos".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
maxWidth: 100, maxWidth: 100,
); );

View File

@@ -15,8 +15,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// which will be permanently deleted after the number of days configure by the admin /// which will be permanently deleted after the number of days configure by the admin
class TrashActionButton extends ConsumerWidget { class TrashActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const TrashActionButton({super.key, required this.source}); const TrashActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -48,6 +50,8 @@ class TrashActionButton extends ConsumerWidget {
maxWidth: 85.0, maxWidth: 85.0,
iconData: Icons.delete_outline_rounded, iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_trash_from_immich".t(context: context), label: "control_bottom_app_bar_trash_from_immich".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -37,8 +37,10 @@ Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {requir
class UnArchiveActionButton extends ConsumerWidget { class UnArchiveActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnArchiveActionButton({super.key, required this.source}); const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async { Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performUnArchiveAction(context, ref, source: source); await performUnArchiveAction(context, ref, source: source);
@@ -49,6 +51,8 @@ class UnArchiveActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.unarchive_outlined, iconData: Icons.unarchive_outlined,
label: "unarchive".t(context: context), label: "unarchive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnFavoriteActionButton extends ConsumerWidget { class UnFavoriteActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem; final bool menuItem;
const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false}); const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -45,6 +46,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
iconData: Icons.favorite_rounded, iconData: Icons.favorite_rounded,
label: "unfavorite".t(context: context), label: "unfavorite".t(context: context),
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
); );
} }

View File

@@ -10,8 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnStackActionButton extends ConsumerWidget { class UnStackActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnStackActionButton({super.key, required this.source}); const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -38,6 +40,8 @@ class UnStackActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.layers_clear_outlined, iconData: Icons.layers_clear_outlined,
label: "unstack".t(context: context), label: "unstack".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -10,8 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UploadActionButton extends ConsumerWidget { class UploadActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UploadActionButton({super.key, required this.source}); const UploadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -39,6 +41,8 @@ class UploadActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.backup_outlined, iconData: Icons.backup_outlined,
label: "upload".t(context: context), label: "upload".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -12,8 +12,10 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
@@ -766,3 +768,68 @@ class AddToAlbumHeader extends ConsumerWidget {
); );
} }
} }
class CreateAlbumButton extends ConsumerWidget {
const CreateAlbumButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
var albumName = await showDialog<String?>(context: context, builder: (context) => const NewAlbumNameModal());
if (albumName == null) {
return;
}
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbum(title: albumName, assetIds: [asset.remoteId!]);
if (album == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
return;
}
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(asset.remoteId!));
context.pop();
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("add_to_album", style: context.textTheme.titleSmall).tr(),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onCreateAlbum,
icon: Icon(Icons.add, color: context.primaryColor),
label: Text(
"common_create_new_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class NewAlbumNameModal extends StatefulWidget {
const NewAlbumNameModal({super.key});
@override
State<NewAlbumNameModal> createState() => _NewAlbumNameModalState();
}
class _NewAlbumNameModalState extends State<NewAlbumNameModal> {
TextEditingController nameController = TextEditingController();
@override
void dispose() {
nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("album_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: nameController,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () {
context.pop(nameController.text.trim());
},
child: Text(
"create_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View File

@@ -79,7 +79,7 @@ class ActivitiesBottomSheet extends HookConsumerWidget {
expand: false, expand: false,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
resizeOnScroll: false, resizeOnScroll: false,
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white, backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
); );
} }
} }

View File

@@ -97,7 +97,7 @@ class AssetViewer extends ConsumerStatefulWidget {
} }
const double _kBottomSheetMinimumExtent = 0.4; const double _kBottomSheetMinimumExtent = 0.4;
const double _kBottomSheetSnapExtent = 0.7; const double _kBottomSheetSnapExtent = 0.67;
class _AssetViewerState extends ConsumerState<AssetViewer> { class _AssetViewerState extends ConsumerState<AssetViewer> {
static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
@@ -399,10 +399,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final isDraggingDown = currentExtent < previousExtent; final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent; previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down // Closes the bottom sheet if the user is dragging down
if (isDraggingDown && delta.extent < 0.55) { if (isDraggingDown && delta.extent < 0.67) {
if (dragInProgress) { if (dragInProgress) {
blockGestures = true; blockGestures = true;
} }
// Jump to a lower position before starting close animation to prevent glitch
if (bottomSheetController.isAttached) {
bottomSheetController.jumpTo(0.67);
}
sheetCloseController?.close(); sheetCloseController?.close();
} }
@@ -480,7 +484,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
previousExtent = _kBottomSheetMinimumExtent; previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet( sheetCloseController = showBottomSheet(
context: ctx, context: ctx,
sheetAnimationStyle: const AnimationStyle(duration: Durations.short4, reverseDuration: Durations.short2), sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2),
constraints: const BoxConstraints(maxWidth: double.infinity), constraints: const BoxConstraints(maxWidth: double.infinity),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))),
backgroundColor: ctx.colorScheme.surfaceContainerLowest, backgroundColor: ctx.colorScheme.surfaceContainerLowest,
@@ -688,17 +692,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
backgroundDecoration: BoxDecoration(color: backgroundColor), backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true, enablePanAlways: true,
), ),
], if (!showingBottomSheet)
), const Positioned(
bottomNavigationBar: showingBottomSheet bottom: 0,
? const SizedBox.shrink() left: 0,
: const Column( right: 0,
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()], children: [AssetStackRow(), ViewerBottomBar()],
), ),
), ),
],
),
),
); );
} }
} }

View File

@@ -38,17 +38,22 @@ class ViewerBottomBar extends ConsumerWidget {
opacity = 0; opacity = 0;
} }
final originalTheme = context.themeData;
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(), if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) const AddActionButton(), if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[ if (isOwner) ...[
asset.isLocalOnly asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer) ? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
], ],
],
]; ];
return IgnorePointer( return IgnorePointer(
@@ -74,7 +79,7 @@ class ViewerBottomBar extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (asset.isVideo) const VideoControls(), if (asset.isVideo) const VideoControls(),
if (!isInLockedView && !isReadonlyModeEnabled) if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
], ],
), ),

View File

@@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -21,14 +20,9 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -48,29 +42,8 @@ class AssetDetailBottomSheet extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
isStacked: asset is RemoteAsset && asset.stackId != null,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
);
final actions = ActionButtonBuilder.build(buttonContext);
return BaseBottomSheet( return BaseBottomSheet(
actions: actions, actions: [],
slivers: const [_AssetDetailBottomSheet()], slivers: const [_AssetDetailBottomSheet()],
controller: controller, controller: controller,
initialChildSize: initialChildSize, initialChildSize: initialChildSize,
@@ -79,7 +52,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
expand: false, expand: false,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
resizeOnScroll: false, resizeOnScroll: false,
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white, backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
); );
} }
} }
@@ -326,7 +299,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
// Appears in (Albums) // Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off // padding at the bottom to avoid cut-off
const SizedBox(height: 100), const SizedBox(height: 30),
], ],
); );
} }

View File

@@ -4,25 +4,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key}); const ViewerTopAppBar({super.key});
@@ -41,15 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final showViewInTimelineButton =
timelineOrigin != TimelineOrigin.main &&
timelineOrigin != TimelineOrigin.deepLink &&
timelineOrigin != TimelineOrigin.trash &&
timelineOrigin != TimelineOrigin.archive &&
timelineOrigin != TimelineOrigin.localAlbum &&
isOwner;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -62,11 +47,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
opacity = 0; opacity = 0;
} }
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final originalTheme = context.themeData;
final actions = <Widget>[ final actions = <Widget>[
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true), if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared) if (album != null && album.isActivityEnabled && album.isShared)
IconButton( IconButton(
icon: const Icon(Icons.chat_outlined), icon: const Icon(Icons.chat_outlined),
@@ -74,28 +58,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
}, },
), ),
if (showViewInTimelineButton)
IconButton(
onPressed: () async {
await context.maybePop();
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
},
icon: const Icon(Icons.image_search),
tooltip: 'view_in_timeline'.t(context: context),
),
if (asset.hasRemote && isOwner && !asset.isFavorite) if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite) if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true), const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
const _KebabMenu(), ViewerKebabMenu(originalTheme: originalTheme),
]; ];
final lockedViewActions = <Widget>[ final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
const _KebabMenu(),
];
return IgnorePointer( return IgnorePointer(
ignoring: opacity < 255, ignoring: opacity < 255,
@@ -122,20 +94,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
Size get preferredSize => const Size.fromHeight(60.0); Size get preferredSize => const Size.fromHeight(60.0);
} }
class _KebabMenu extends ConsumerWidget {
const _KebabMenu();
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
onPressed: () {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
},
icon: const Icon(Icons.more_vert_rounded),
);
}
}
class _AppBarBackButton extends ConsumerWidget { class _AppBarBackButton extends ConsumerWidget {
const _AppBarBackButton(); const _AppBarBackButton();

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
@@ -104,7 +105,12 @@ class NativeVideoViewer extends HookConsumerWidget {
throw Exception('No file found for the video'); throw Exception('No file found for the video');
} }
final source = await VideoSource.init(path: file.path, type: VideoSourceType.file); // Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
final source = await VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
return source; return source;
} }

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
class ViewerKebabMenu extends ConsumerWidget {
const ViewerKebabMenu({super.key, this.originalTheme});
final ThemeData? originalTheme;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final actionContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isStacked: asset is RemoteAsset && asset.stackId != null,
isInLockedView: isInLockedView,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: const Icon(Icons.more_vert_rounded),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View File

@@ -14,7 +14,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -47,10 +46,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline),
],
], ],
); );
} }

View File

@@ -81,7 +81,7 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
child: CustomScrollView( child: CustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
const SliverPersistentHeader(delegate: _DragHandleDelegate(), pinned: true), const SliverToBoxAdapter(child: _DragHandle()),
if (widget.actions.isNotEmpty) if (widget.actions.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
@@ -108,31 +108,13 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
} }
} }
class _DragHandleDelegate extends SliverPersistentHeaderDelegate {
const _DragHandleDelegate();
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return const _DragHandle();
}
@override
bool shouldRebuild(_DragHandleDelegate oldDelegate) => false;
@override
double get minExtent => 50.0;
@override
double get maxExtent => 50.0;
}
class _DragHandle extends StatelessWidget { class _DragHandle extends StatelessWidget {
const _DragHandle(); const _DragHandle();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 50, height: 38,
child: Center( child: Center(
child: SizedBox( child: SizedBox(
width: 32, width: 32,

View File

@@ -17,7 +17,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -86,10 +85,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline),
],
], ],
slivers: multiselect.hasRemote slivers: multiselect.hasRemote
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)] ? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]

View File

@@ -18,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -112,10 +111,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
], ],
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline),
],
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id), if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
], ],
slivers: ownsAlbum slivers: ownsAlbum

View File

@@ -324,7 +324,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0; const scrubberBottomPadding = 100.0;
final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding); const bottomSheetOpenModifier = 120.0;
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final grid = CustomScrollView( final grid = CustomScrollView(
primary: true, primary: true,
@@ -347,7 +351,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false, addRepaintBoundaries: false,
), ),
), ),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)), SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
], ],
); );

View File

@@ -17,8 +17,10 @@ class PersonApiRepository extends ApiRepository {
} }
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async { Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name, birthDate: birthday))); final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
return _toPerson(dto); final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc);
final response = await checkNull(_api.updatePerson(id, dto));
return _toPerson(response);
} }
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(

View File

@@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/settings/sync_status.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
import 'package:immich_mobile/presentation/pages/download_info.page.dart'; import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart'; import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
@@ -167,7 +167,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]), AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: ChangePasswordRoute.page), AutoRoute(page: ChangePasswordRoute.page),
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false), AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
CustomRoute( AutoRoute(
page: TabControllerRoute.page, page: TabControllerRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
children: [ children: [
@@ -176,9 +176,8 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
], ],
transitionsBuilder: TransitionsBuilders.fadeIn,
), ),
CustomRoute( AutoRoute(
page: TabShellRoute.page, page: TabShellRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
children: [ children: [
@@ -187,7 +186,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
], ],
transitionsBuilder: TransitionsBuilders.fadeIn,
), ),
CustomRoute( CustomRoute(
page: GalleryViewerRoute.page, page: GalleryViewerRoute.page,
@@ -288,7 +286,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LockedRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]), AutoRoute(page: LockedRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: PinAuthRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: PinAuthRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: FeatInDevRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LocalMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: LocalMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupRoute.page, guards: [_authGuard, _duplicateGuard]),
@@ -340,6 +337,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart // required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722 // auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'), RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -1648,22 +1648,6 @@ class FavoritesRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [FeatInDevPage]
class FeatInDevRoute extends PageRouteInfo<void> {
const FeatInDevRoute({List<PageRouteInfo>? children})
: super(FeatInDevRoute.name, initialChildren: children);
static const String name = 'FeatInDevRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const FeatInDevPage();
},
);
}
/// generated route for /// generated route for
/// [FilterImagePage] /// [FilterImagePage]
class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> { class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
@@ -1831,6 +1815,22 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [ImmichUIShowcasePage]
class ImmichUIShowcaseRoute extends PageRouteInfo<void> {
const ImmichUIShowcaseRoute({List<PageRouteInfo>? children})
: super(ImmichUIShowcaseRoute.name, initialChildren: children);
static const String name = 'ImmichUIShowcaseRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const ImmichUIShowcasePage();
},
);
}
/// generated route for /// generated route for
/// [LibraryPage] /// [LibraryPage]
class LibraryRoute extends PageRouteInfo<void> { class LibraryRoute extends PageRouteInfo<void> {

View File

@@ -27,7 +27,7 @@ enum AppSettingsEnum<T> {
thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000), thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000),
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350), imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200), albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200),
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 0), selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false), manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5 logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
@@ -42,7 +42,7 @@ enum AppSettingsEnum<T> {
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0), mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false), allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false), ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, false), selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true), enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false), syncAlbums<bool>(StoreKey.syncAlbums, null, false),
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false), autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),

View File

@@ -1,9 +1,17 @@
import 'package:flutter/widgets.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -19,6 +27,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext { class ActionButtonContext {
final BaseAsset asset; final BaseAsset asset;
@@ -30,6 +39,9 @@ class ActionButtonContext {
final RemoteAlbum? currentAlbum; final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting; final bool advancedTroubleshooting;
final ActionSource source; final ActionSource source;
final bool isCasting;
final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme;
const ActionButtonContext({ const ActionButtonContext({
required this.asset, required this.asset,
@@ -41,27 +53,33 @@ class ActionButtonContext {
required this.currentAlbum, required this.currentAlbum,
required this.advancedTroubleshooting, required this.advancedTroubleshooting,
required this.source, required this.source,
this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main,
this.originalTheme,
}); });
} }
enum ActionButtonType { enum ActionButtonType {
advancedInfo, openInfo,
likeActivity,
share, share,
shareLink, shareLink,
cast,
similarPhotos, similarPhotos,
viewInTimeline,
download,
upload,
unstack,
archive, archive,
unarchive, unarchive,
download,
trash,
deletePermanent,
delete,
moveToLockFolder, moveToLockFolder,
removeFromLockFolder, removeFromLockFolder,
deleteLocal,
upload,
removeFromAlbum, removeFromAlbum,
unstack, trash,
likeActivity; deleteLocal,
deletePermanent,
delete,
advancedInfo;
bool shouldShow(ActionButtonContext context) { bool shouldShow(ActionButtonContext context) {
return switch (this) { return switch (this) {
@@ -128,39 +146,165 @@ enum ActionButtonType {
ActionButtonType.similarPhotos => ActionButtonType.similarPhotos =>
!context.isInLockedView && // !context.isInLockedView && //
context.asset is RemoteAsset, context.asset is RemoteAsset,
ActionButtonType.openInfo => true,
ActionButtonType.viewInTimeline =>
context.timelineOrigin != TimelineOrigin.main &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.timelineOrigin != TimelineOrigin.trash &&
context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
}; };
} }
Widget buildButton(ActionButtonContext context) { ConsumerWidget buildButton(
ActionButtonContext context, [
BuildContext? buildContext,
bool iconOnly = false,
bool menuItem = false,
]) {
return switch (this) { return switch (this) {
ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source), ActionButtonType.advancedInfo => AdvancedInfoActionButton(
ActionButtonType.share => ShareActionButton(source: context.source), source: context.source,
ActionButtonType.shareLink => ShareLinkActionButton(source: context.source), iconOnly: iconOnly,
ActionButtonType.archive => ArchiveActionButton(source: context.source), menuItem: menuItem,
ActionButtonType.unarchive => UnArchiveActionButton(source: context.source), ),
ActionButtonType.download => DownloadActionButton(source: context.source), ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source), ActionButtonType.shareLink => ShareLinkActionButton(
ActionButtonType.deletePermanent => DeletePermanentActionButton(source: context.source), source: context.source,
ActionButtonType.delete => DeleteActionButton(source: context.source), iconOnly: iconOnly,
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(source: context.source), menuItem: menuItem,
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(source: context.source), ),
ActionButtonType.deleteLocal => DeleteLocalActionButton(source: context.source), ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.upload => UploadActionButton(source: context.source), ActionButtonType.unarchive => UnArchiveActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.deleteLocal => DeleteLocalActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton( ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
albumId: context.currentAlbum!.id, albumId: context.currentAlbum!.id,
source: context.source, source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
), ),
ActionButtonType.likeActivity => const LikeActivityActionButton(), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.similarPhotos => SimilarPhotosActionButton(assetId: (context.asset as RemoteAsset).id), ActionButtonType.similarPhotos => SimilarPhotosActionButton(
assetId: (context.asset as RemoteAsset).id,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.openInfo => BaseActionButton(
label: 'info'.tr(),
iconData: Icons.info_outline,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
),
ActionButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.tr(),
iconData: Icons.image_search,
iconColor: context.originalTheme?.iconTheme.color,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: buildContext == null
? null
: () async {
await buildContext.maybePop();
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
}; };
} }
/// Defines which group each button belongs to for kebab menu.
/// Buttons in the same group will be displayed together,
/// with dividers separating different groups.
int get kebabMenuGroup => switch (this) {
// 0: info
ActionButtonType.openInfo => 0,
// 10: move,remove, and delete
ActionButtonType.trash => 10,
ActionButtonType.deletePermanent => 10,
ActionButtonType.removeFromLockFolder => 10,
ActionButtonType.removeFromAlbum => 10,
ActionButtonType.unstack => 10,
ActionButtonType.archive => 10,
ActionButtonType.unarchive => 10,
ActionButtonType.moveToLockFolder => 10,
ActionButtonType.deleteLocal => 10,
ActionButtonType.delete => 10,
// 90: advancedInfo
ActionButtonType.advancedInfo => 90,
// 1: others
_ => 1,
};
} }
class ActionButtonBuilder { class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = ActionButtonType.values; static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
static const List<ActionButtonType> defaultViewerKebabMenuOrder = _actionTypes;
static const Set<ActionButtonType> defaultViewerBottomBarButtons = {
ActionButtonType.share,
ActionButtonType.moveToLockFolder,
ActionButtonType.upload,
ActionButtonType.delete,
ActionButtonType.archive,
ActionButtonType.unarchive,
};
static List<Widget> build(ActionButtonContext context) { static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
} }
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList();
if (visibleButtons.isEmpty) {
return [];
}
final List<Widget> result = [];
int? lastGroup;
for (final type in visibleButtons) {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
lastGroup = type.kebabMenuGroup;
}
return result;
}
} }

View File

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
@@ -68,11 +69,11 @@ class ActivityTextField extends HookConsumerWidget {
suffixIcon: Padding( suffixIcon: Padding(
padding: const EdgeInsets.only(right: 10), padding: const EdgeInsets.only(right: 10),
child: IconButton( child: IconButton(
icon: Icon(liked ? Icons.favorite_rounded : Icons.favorite_border_rounded), icon: Icon(liked ? Icons.thumb_up : Icons.thumb_up_off_alt),
onPressed: liked ? removeLike : addLike, onPressed: liked ? removeLike : addLike,
), ),
), ),
suffixIconColor: liked ? Colors.red[700] : null, suffixIconColor: liked ? context.primaryColor : null,
hintText: !isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(), hintText: !isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]), hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
), ),

View File

@@ -37,7 +37,7 @@ class ActivityTile extends HookConsumerWidget {
? Container( ? Container(
width: isBottomSheet ? 30 : 44, width: isBottomSheet ? 30 : 44,
alignment: Alignment.center, alignment: Alignment.center,
child: Icon(Icons.favorite_rounded, color: Colors.red[700]), child: Icon(Icons.thumb_up, color: context.primaryColor),
) )
: isBottomSheet : isBottomSheet
? UserCircleAvatar(user: activity.user, size: 30, radius: 15) ? UserCircleAvatar(user: activity.user, size: 30, radius: 15)

View File

@@ -67,8 +67,8 @@ class CommentBubble extends ConsumerWidget {
bottom: 6, bottom: 6,
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), decoration: BoxDecoration(color: context.colorScheme.surfaceContainer, shape: BoxShape.circle),
child: Icon(Icons.favorite, color: Colors.red[600], size: 18), child: Icon(Icons.thumb_up, color: context.primaryColor, size: 18),
), ),
), ),
], ],
@@ -81,8 +81,8 @@ class CommentBubble extends ConsumerWidget {
if (isLike && !showThumbnail) { if (isLike && !showThumbnail) {
likes = Container( likes = Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), decoration: BoxDecoration(color: context.colorScheme.surfaceContainer, shape: BoxShape.circle),
child: Icon(Icons.favorite, color: Colors.red[600], size: 18), child: Icon(Icons.thumb_up, color: context.primaryColor, size: 18),
); );
} }

View File

@@ -53,16 +53,18 @@ class ServerUpdateNotification extends HookConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Expanded(
child: Text(
serverInfoState.versionStatus.message, serverInfoState.versionStatus.message,
textAlign: TextAlign.start, textAlign: TextAlign.start,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge, style: context.textTheme.labelLarge,
), ),
),
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate || if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate ||
serverInfoState.versionStatus == VersionStatus.clientOutOfDate) ...[ serverInfoState.versionStatus == VersionStatus.clientOutOfDate) ...[
const Spacer(), const SizedBox(width: 8),
TextButton( TextButton(
onPressed: openUpdateLink, onPressed: openUpdateLink,
style: TextButton.styleFrom( style: TextButton.styleFrom(

View File

@@ -155,8 +155,8 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if (kDebugMode || kProfileMode) if (kDebugMode || kProfileMode)
IconButton( IconButton(
icon: const Icon(Icons.science_rounded), icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()), onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
), ),
if (isCasting) if (isCasting)
Padding( Padding(

View File

@@ -74,8 +74,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
IconButton( IconButton(
icon: const Icon(Icons.science_rounded), icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()), onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
), ),
if (showUploadButton && !isReadonlyModeEnabled) if (showUploadButton && !isReadonlyModeEnabled)
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),

View File

@@ -2,26 +2,27 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
class EntitiyCountTile extends StatelessWidget { class EntityCountTile extends StatelessWidget {
final int count; final int count;
final String label; final String label;
final IconData icon; final IconData icon;
const EntitiyCountTile({super.key, required this.count, required this.label, required this.icon}); const EntityCountTile({super.key, required this.count, required this.label, required this.icon});
String zeroPadding(int number, int targetWidth) { String zeroPadding(int number, int targetWidth) {
final numStr = number.toString(); final numStr = number.toString();
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : ""; return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
} }
int calculateMaxDigits(double availableWidth) {
const double charWidth = 11.0;
return (availableWidth / charWidth).floor().clamp(1, 8);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final availableWidth = (screenWidth - 32 - 8) / 2;
const double charWidth = 11.0;
final maxDigits = ((availableWidth - 32) / charWidth).floor().clamp(1, 8);
return Container( return Container(
height: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow, color: context.colorScheme.surfaceContainerLow,
@@ -29,7 +30,6 @@ class EntitiyCountTile extends StatelessWidget {
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)), border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Icon and Label // Icon and Label
@@ -38,18 +38,17 @@ class EntitiyCountTile extends StatelessWidget {
children: [ children: [
Icon(icon, color: context.primaryColor), Icon(icon, color: context.primaryColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Flexible(
child: Text(
label, label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16), style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
), ),
),
], ],
), ),
const SizedBox(height: 12),
// Number // Number
LayoutBuilder( const Spacer(),
builder: (context, constraints) { RichText(
final maxDigits = calculateMaxDigits(constraints.maxWidth);
return RichText(
text: TextSpan( text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600), style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
children: [ children: [
@@ -63,8 +62,6 @@ class EntitiyCountTile extends StatelessWidget {
), ),
], ],
), ),
);
},
), ),
], ],
), ),

View File

@@ -282,20 +282,24 @@ class _SyncStatsCounts extends ConsumerWidget {
_SectionHeaderText(text: "assets".t(context: context)), _SectionHeaderText(text: "assets".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
// 1. Wrap in IntrinsicHeight
child: IntrinsicHeight(
child: Flex( child: Flex(
direction: Axis.horizontal, direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
// 2. Stretch children vertically to fill the IntrinsicHeight
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8.0, spacing: 8.0,
children: [ children: [
Expanded( Expanded(
child: EntitiyCountTile( child: EntityCountTile(
label: "local".t(context: context), label: "local".t(context: context),
count: localAssetCount, count: localAssetCount,
icon: Icons.smartphone, icon: Icons.smartphone,
), ),
), ),
Expanded( Expanded(
child: EntitiyCountTile( child: EntityCountTile(
label: "remote".t(context: context), label: "remote".t(context: context),
count: remoteAssetCount, count: remoteAssetCount,
icon: Icons.cloud, icon: Icons.cloud,
@@ -304,23 +308,26 @@ class _SyncStatsCounts extends ConsumerWidget {
], ],
), ),
), ),
),
_SectionHeaderText(text: "albums".t(context: context)), _SectionHeaderText(text: "albums".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight(
child: Flex( child: Flex(
direction: Axis.horizontal, direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0, spacing: 8.0,
children: [ children: [
Expanded( Expanded(
child: EntitiyCountTile( child: EntityCountTile(
label: "local".t(context: context), label: "local".t(context: context),
count: localAlbumCount, count: localAlbumCount,
icon: Icons.smartphone, icon: Icons.smartphone,
), ),
), ),
Expanded( Expanded(
child: EntitiyCountTile( child: EntityCountTile(
label: "remote".t(context: context), label: "remote".t(context: context),
count: remoteAlbumCount, count: remoteAlbumCount,
icon: Icons.cloud, icon: Icons.cloud,
@@ -329,23 +336,26 @@ class _SyncStatsCounts extends ConsumerWidget {
], ],
), ),
), ),
),
_SectionHeaderText(text: "other".t(context: context)), _SectionHeaderText(text: "other".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight(
child: Flex( child: Flex(
direction: Axis.horizontal, direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0, spacing: 8.0,
children: [ children: [
Expanded( Expanded(
child: EntitiyCountTile( child: EntityCountTile(
label: "memories".t(context: context), label: "memories".t(context: context),
count: memoryCount, count: memoryCount,
icon: Icons.calendar_today, icon: Icons.calendar_today,
), ),
), ),
Expanded( Expanded(
child: EntitiyCountTile( child: EntityCountTile(
label: "hashed_assets".t(context: context), label: "hashed_assets".t(context: context),
count: localHashedCount, count: localHashedCount,
icon: Icons.tag, icon: Icons.tag,
@@ -354,6 +364,7 @@ class _SyncStatsCounts extends ConsumerWidget {
], ],
), ),
), ),
),
// To be removed once the experimental feature is stable // To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid && if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[ appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
@@ -364,20 +375,22 @@ class _SyncStatsCounts extends ConsumerWidget {
return counts.when( return counts.when(
data: (c) => Padding( data: (c) => Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight(
child: Flex( child: Flex(
direction: Axis.horizontal, direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0, spacing: 8.0,
children: [ children: [
Expanded( Expanded(
child: EntitiyCountTile( child: EntityCountTile(
label: "local".t(context: context), label: "local".t(context: context),
count: c.total, count: c.total,
icon: Icons.delete_outline, icon: Icons.delete_outline,
), ),
), ),
Expanded( Expanded(
child: EntitiyCountTile( child: EntityCountTile(
label: "hashed_assets".t(context: context), label: "hashed_assets".t(context: context),
count: c.hashed, count: c.hashed,
icon: Icons.tag, icon: Icons.tag,
@@ -386,6 +399,7 @@ class _SyncStatsCounts extends ConsumerWidget {
], ],
), ),
), ),
),
loading: () => const CircularProgressIndicator(), loading: () => const CircularProgressIndicator(),
error: (e, st) => Text('Error: $e'), error: (e, st) => Text('Error: $e'),
); );

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.3.1 - API version: 2.4.0
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -199,7 +199,6 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people *PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person *PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin *PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins *PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue *QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue *QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
@@ -466,10 +465,9 @@ Class | Method | HTTP request | Description
- [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginActionResponseDto](doc//PluginActionResponseDto.md) - [PluginActionResponseDto](doc//PluginActionResponseDto.md)
- [PluginContextType](doc//PluginContextType.md) - [PluginContext](doc//PluginContext.md)
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md) - [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md) - [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
- [PluginTriggerType](doc//PluginTriggerType.md) - [PluginTriggerType](doc//PluginTriggerType.md)
- [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md) - [PurchaseUpdate](doc//PurchaseUpdate.md)

View File

@@ -217,10 +217,9 @@ part 'model/pin_code_reset_dto.dart';
part 'model/pin_code_setup_dto.dart'; part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart'; part 'model/places_response_dto.dart';
part 'model/plugin_action_response_dto.dart'; part 'model/plugin_action_response_dto.dart';
part 'model/plugin_context_type.dart'; part 'model/plugin_context.dart';
part 'model/plugin_filter_response_dto.dart'; part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_response_dto.dart'; part 'model/plugin_response_dto.dart';
part 'model/plugin_trigger_response_dto.dart';
part 'model/plugin_trigger_type.dart'; part 'model/plugin_trigger_type.dart';
part 'model/purchase_response.dart'; part 'model/purchase_response.dart';
part 'model/purchase_update.dart'; part 'model/purchase_update.dart';

View File

@@ -73,57 +73,6 @@ class PluginsApi {
return null; return null;
} }
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginTriggersWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/triggers';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
final response = await getPluginTriggersWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTriggerResponseDto>') as List)
.cast<PluginTriggerResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all plugins /// List all plugins
/// ///
/// Retrieve a list of plugins available to the authenticated user. /// Retrieve a list of plugins available to the authenticated user.

View File

@@ -482,14 +482,12 @@ class ApiClient {
return PlacesResponseDto.fromJson(value); return PlacesResponseDto.fromJson(value);
case 'PluginActionResponseDto': case 'PluginActionResponseDto':
return PluginActionResponseDto.fromJson(value); return PluginActionResponseDto.fromJson(value);
case 'PluginContextType': case 'PluginContext':
return PluginContextTypeTypeTransformer().decode(value); return PluginContextTypeTransformer().decode(value);
case 'PluginFilterResponseDto': case 'PluginFilterResponseDto':
return PluginFilterResponseDto.fromJson(value); return PluginFilterResponseDto.fromJson(value);
case 'PluginResponseDto': case 'PluginResponseDto':
return PluginResponseDto.fromJson(value); return PluginResponseDto.fromJson(value);
case 'PluginTriggerResponseDto':
return PluginTriggerResponseDto.fromJson(value);
case 'PluginTriggerType': case 'PluginTriggerType':
return PluginTriggerTypeTypeTransformer().decode(value); return PluginTriggerTypeTypeTransformer().decode(value);
case 'PurchaseResponse': case 'PurchaseResponse':

View File

@@ -127,8 +127,8 @@ String parameterToString(dynamic value) {
if (value is Permission) { if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString(); return PermissionTypeTransformer().encode(value).toString();
} }
if (value is PluginContextType) { if (value is PluginContext) {
return PluginContextTypeTypeTransformer().encode(value).toString(); return PluginContextTypeTransformer().encode(value).toString();
} }
if (value is PluginTriggerType) { if (value is PluginTriggerType) {
return PluginTriggerTypeTypeTransformer().encode(value).toString(); return PluginTriggerTypeTypeTransformer().encode(value).toString();

View File

@@ -32,7 +32,7 @@ class PluginActionResponseDto {
Object? schema; Object? schema;
List<PluginContextType> supportedContexts; List<PluginContext> supportedContexts;
String title; String title;
@@ -90,7 +90,7 @@ class PluginActionResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!, methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!, pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'), schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']), supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!, title: mapValueOfType<String>(json, r'title')!,
); );
} }

View File

@@ -11,9 +11,9 @@
part of openapi.api; part of openapi.api;
class PluginContextType { class PluginContext {
/// Instantiate a new enum with the provided [value]. /// Instantiate a new enum with the provided [value].
const PluginContextType._(this.value); const PluginContext._(this.value);
/// The underlying value of this enum member. /// The underlying value of this enum member.
final String value; final String value;
@@ -23,24 +23,24 @@ class PluginContextType {
String toJson() => value; String toJson() => value;
static const asset = PluginContextType._(r'asset'); static const asset = PluginContext._(r'asset');
static const album = PluginContextType._(r'album'); static const album = PluginContext._(r'album');
static const person = PluginContextType._(r'person'); static const person = PluginContext._(r'person');
/// List of all possible values in this [enum][PluginContextType]. /// List of all possible values in this [enum][PluginContext].
static const values = <PluginContextType>[ static const values = <PluginContext>[
asset, asset,
album, album,
person, person,
]; ];
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value); static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value);
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) { static List<PluginContext> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContextType>[]; final result = <PluginContext>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = PluginContextType.fromJson(row); final value = PluginContext.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@@ -50,16 +50,16 @@ class PluginContextType {
} }
} }
/// Transformation class that can [encode] an instance of [PluginContextType] to String, /// Transformation class that can [encode] an instance of [PluginContext] to String,
/// and [decode] dynamic data back to [PluginContextType]. /// and [decode] dynamic data back to [PluginContext].
class PluginContextTypeTypeTransformer { class PluginContextTypeTransformer {
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._(); factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._();
const PluginContextTypeTypeTransformer._(); const PluginContextTypeTransformer._();
String encode(PluginContextType data) => data.value; String encode(PluginContext data) => data.value;
/// Decodes a [dynamic value][data] to a PluginContextType. /// Decodes a [dynamic value][data] to a PluginContext.
/// ///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data] /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -67,12 +67,12 @@ class PluginContextTypeTypeTransformer {
/// ///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code. /// and users are still using an old app with the old code.
PluginContextType? decode(dynamic data, {bool allowNull = true}) { PluginContext? decode(dynamic data, {bool allowNull = true}) {
if (data != null) { if (data != null) {
switch (data) { switch (data) {
case r'asset': return PluginContextType.asset; case r'asset': return PluginContext.asset;
case r'album': return PluginContextType.album; case r'album': return PluginContext.album;
case r'person': return PluginContextType.person; case r'person': return PluginContext.person;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
@@ -82,7 +82,7 @@ class PluginContextTypeTypeTransformer {
return null; return null;
} }
/// Singleton [PluginContextTypeTypeTransformer] instance. /// Singleton [PluginContextTypeTransformer] instance.
static PluginContextTypeTypeTransformer? _instance; static PluginContextTypeTransformer? _instance;
} }

View File

@@ -32,7 +32,7 @@ class PluginFilterResponseDto {
Object? schema; Object? schema;
List<PluginContextType> supportedContexts; List<PluginContext> supportedContexts;
String title; String title;
@@ -90,7 +90,7 @@ class PluginFilterResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!, methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!, pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'), schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']), supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!, title: mapValueOfType<String>(json, r'title')!,
); );
} }

View File

@@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTriggerResponseDto {
/// Returns a new [PluginTriggerResponseDto] instance.
PluginTriggerResponseDto({
required this.contextType,
required this.type,
});
PluginContextType contextType;
PluginTriggerType type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
other.contextType == contextType &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(contextType.hashCode) +
(type.hashCode);
@override
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'contextType'] = this.contextType;
json[r'type'] = this.type;
return json;
}
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTriggerResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTriggerResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTriggerResponseDto(
contextType: PluginContextType.fromJson(json[r'contextType'])!,
type: PluginTriggerType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTriggerResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTriggerResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTriggerResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTriggerResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTriggerResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'contextType',
'type',
};
}

View File

@@ -40,7 +40,7 @@ class WorkflowResponseDto {
String ownerId; String ownerId;
PluginTriggerType triggerType; WorkflowResponseDtoTriggerTypeEnum triggerType;
@override @override
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto && bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
@@ -105,7 +105,7 @@ class WorkflowResponseDto {
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
ownerId: mapValueOfType<String>(json, r'ownerId')!, ownerId: mapValueOfType<String>(json, r'ownerId')!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!,
); );
} }
return null; return null;
@@ -165,3 +165,77 @@ class WorkflowResponseDto {
}; };
} }
class WorkflowResponseDtoTriggerTypeEnum {
/// Instantiate a new enum with the provided [value].
const WorkflowResponseDtoTriggerTypeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate');
static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum].
static const values = <WorkflowResponseDtoTriggerTypeEnum>[
assetCreate,
personRecognized,
];
static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value);
static List<WorkflowResponseDtoTriggerTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowResponseDtoTriggerTypeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String,
/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum].
class WorkflowResponseDtoTriggerTypeEnumTypeTransformer {
factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate;
case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance.
static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance;
}

View File

@@ -18,7 +18,6 @@ class WorkflowUpdateDto {
this.enabled, this.enabled,
this.filters = const [], this.filters = const [],
this.name, this.name,
this.triggerType,
}); });
List<WorkflowActionItemDto> actions; List<WorkflowActionItemDto> actions;
@@ -49,22 +48,13 @@ class WorkflowUpdateDto {
/// ///
String? name; String? name;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
PluginTriggerType? triggerType;
@override @override
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto && bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
_deepEquality.equals(other.actions, actions) && _deepEquality.equals(other.actions, actions) &&
other.description == description && other.description == description &&
other.enabled == enabled && other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) && _deepEquality.equals(other.filters, filters) &&
other.name == name && other.name == name;
other.triggerType == triggerType;
@override @override
int get hashCode => int get hashCode =>
@@ -73,11 +63,10 @@ class WorkflowUpdateDto {
(description == null ? 0 : description!.hashCode) + (description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) + (enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) + (filters.hashCode) +
(name == null ? 0 : name!.hashCode) + (name == null ? 0 : name!.hashCode);
(triggerType == null ? 0 : triggerType!.hashCode);
@override @override
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -98,11 +87,6 @@ class WorkflowUpdateDto {
} else { } else {
// json[r'name'] = null; // json[r'name'] = null;
} }
if (this.triggerType != null) {
json[r'triggerType'] = this.triggerType;
} else {
// json[r'triggerType'] = null;
}
return json; return json;
} }
@@ -120,7 +104,6 @@ class WorkflowUpdateDto {
enabled: mapValueOfType<bool>(json, r'enabled'), enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
); );
} }
return null; return null;

View File

@@ -0,0 +1,3 @@
export 'src/buttons/close_button.dart';
export 'src/buttons/icon_button.dart';
export 'src/types.dart';

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/buttons/icon_button.dart';
import 'package:immich_ui/src/types.dart';
class ImmichCloseButton extends StatelessWidget {
final VoidCallback? onTap;
final ImmichVariant variant;
final ImmichColor color;
const ImmichCloseButton({
super.key,
this.onTap,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.ghost,
});
@override
Widget build(BuildContext context) => ImmichIconButton(
key: key,
icon: Icons.close,
color: color,
variant: variant,
onTap: onTap ?? () => Navigator.of(context).pop(),
);
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/types.dart';
class ImmichIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final ImmichVariant variant;
final ImmichColor color;
const ImmichIconButton({
super.key,
required this.icon,
required this.onTap,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
});
@override
Widget build(BuildContext context) {
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.onPrimary,
ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
},
};
return IconButton(
icon: Icon(icon),
onPressed: onTap,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
),
);
}
}

View File

@@ -0,0 +1,9 @@
enum ImmichVariant {
filled,
ghost,
}
enum ImmichColor {
primary,
secondary,
}

View File

@@ -0,0 +1,55 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
sdks:
dart: ">=3.8.0-0 <4.0.0"

View File

@@ -0,0 +1,12 @@
name: immich_ui
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter:
uses-material-design: true

View File

@@ -1015,6 +1015,13 @@ packages:
relative: true relative: true
source: path source: path
version: "0.0.0" version: "0.0.0"
immich_ui:
dependency: "direct main"
description:
path: "packages/ui"
relative: true
source: path
version: "0.0.0"
integration_test: integration_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 2.3.1+3028 version: 2.4.0+3029
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
@@ -43,6 +43,8 @@ dependencies:
hooks_riverpod: ^2.6.1 hooks_riverpod: ^2.6.1
http: ^1.5.0 http: ^1.5.0
image_picker: ^1.2.0 image_picker: ^1.2.0
immich_ui:
path: './packages/ui'
intl: ^0.20.2 intl: ^0.20.2
isar: isar:
git: git:

View File

@@ -77,15 +77,15 @@ void main() {
overrides: overrides, overrides: overrides,
); );
expect(find.widgetWithIcon(IconButton, Icons.favorite_rounded), findsOneWidget); expect(find.widgetWithIcon(IconButton, Icons.thumb_up), findsOneWidget);
expect(find.widgetWithIcon(IconButton, Icons.favorite_border_rounded), findsNothing); expect(find.widgetWithIcon(IconButton, Icons.thumb_up_off_alt), findsNothing);
}); });
testWidgets('Bordered icon if likedId == null', (tester) async { testWidgets('Bordered icon if likedId == null', (tester) async {
await tester.pumpConsumerWidget(ActivityTextField(onSubmit: (_) {}), overrides: overrides); await tester.pumpConsumerWidget(ActivityTextField(onSubmit: (_) {}), overrides: overrides);
expect(find.widgetWithIcon(IconButton, Icons.favorite_border_rounded), findsOneWidget); expect(find.widgetWithIcon(IconButton, Icons.thumb_up_off_alt), findsOneWidget);
expect(find.widgetWithIcon(IconButton, Icons.favorite_rounded), findsNothing); expect(find.widgetWithIcon(IconButton, Icons.thumb_up), findsNothing);
}); });
testWidgets('Adds new like', (tester) async { testWidgets('Adds new like', (tester) async {

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