Compare commits

...

29 Commits

Author SHA1 Message Date
Immich Release Bot
2cd45ed1de Version v1.46.0 2023-02-09 17:59:47 +00:00
Alex
263598f2cf fix(server): get shared link album info doesn't contain owner property (#1708) 2023-02-09 11:55:24 -06:00
Alex
1d1d71c779 [Localizely] Translations update (#1707) 2023-02-09 11:36:46 -06:00
Matthias Rupp
fd13265131 feat(mobile): Home screen customization options (#1563)
* Try staggered layout for home page

* Introduce setting for dynamic layout

* Fix some provider related bugs

* Make asset grouping configurable

* Add translation keys, refactor group title

* Rename enum values

* Fix enum names

* Reformat long if statement

* Fix timezone related bug

* Minor clean up

* Fix unit test

* Add second assets check back to home screen
2023-02-09 11:35:44 -06:00
Fynn Petersen-Frey
911c35a7f1 refactor(mobile): add Isar DB & Store class (#1574)
* refactor(mobile): add Isar DB & Store class

new Store: globally accessible key-value store like Hive (but based on Isar)

replace first few places of Hive usage with the new Store

* reduce max. DB size to prevent errors on older iOS devices

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-02-09 11:32:08 -06:00
Michel Heusschen
adb265794c feat(web): allow uploading more file types (#1570)
* feat(web): allow uploading more file types

* fix(web): make filename extension lowercase
2023-02-09 10:08:19 -06:00
Alex
8c20d8cb3d fix(server): Create album response doesn't have owner property as required (#1704) 2023-02-08 17:15:32 -06:00
Skyler Mäntysaari
43359f1d26 feat(proxy): Initial IPv6 support (#1577) 2023-02-08 13:53:48 -06:00
martyfuhry
dc9da7480c feat(mobile): Responsive layout improvements with a navigation rail and album grid (#1583) 2023-02-08 13:42:45 -06:00
Alex Tran
18647203cc fix(GitHub): feature request form has wrong type for textarea 2023-02-07 22:19:50 -06:00
Alex Tran
be8f2c01a2 fix(GitHub): feature request template 2023-02-07 22:18:42 -06:00
Alex
c8fffe4ade feat(GitHub): update bug and feature request template (#1584)
* dev: Reusing template from Home Assistant

* dev: add bug report template

* fix: template

* dev: change type

* dev:

* dev: add default labels

* dev: Add default title

* dev: add feature request template

* remove feature request from markdown

* dev: frontmatter
2023-02-07 22:09:15 -06:00
Alex
43fd7737f1 chore(server): make owner as required response for AlbumResponseDto (#1579) 2023-02-07 15:11:14 -06:00
dependabot[bot]
fb4969d5d1 chore(deps): bump docker/setup-buildx-action from 2.4.0 to 2.4.1 (#1575)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.4.0 to 2.4.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.4.0...v2.4.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-07 08:31:30 -06:00
Alex
3cc4af5947 chore(server) Add user FK to album entity (#1569) 2023-02-06 20:47:06 -06:00
Zack Pollard
ac39ebddc0 chore(server): update package-lock.json to match package.json (#1573) 2023-02-06 22:40:31 +00:00
Alex
29bb1f7ef2 feat(server): add updatedAt to Asset, Album and User (#1566)
* feat: add updatedAt info to DTO and generate api

* chore: remove unsued file

* chore: Add update statement to add/remove asset/user to album

* fix: test
2023-02-06 10:24:58 -06:00
martyfuhry
b8d2f5b373 feat(mobile): Multiselect add to favorite from the timeline (#1558)
* multiselect add to favorites
2023-02-06 07:59:56 -05:00
Fynn Petersen-Frey
2139853dd9 refactor(mobile): introduce Album & User classes (#1561)
replace usages of AlbumResponseDto with Album
replace usages of UserResponseDto with User
2023-02-06 01:13:32 -06:00
martyfuhry
527aa61a87 fix(mobile): Added flutter native splash and splash screens (#1520)
* rebasing

* added launch background image to repository

---------

Co-authored-by: Marty Fuhry <marty@fuhry.farm>
2023-02-06 00:41:07 -06:00
James
4261fc8a04 feat(deployment): support docker secrets (#1254)
* Support secrets

* Rewrite to support sh

* Remove JWT_SECRET
2023-02-05 23:38:06 -06:00
Alex
7dbddba757 chore(server): remove token when logged out (#1560)
* chore(mobile): invoke logout() on mobile app

* feat: add mechanism to delete token from logging out endpoint

* fix: set state after login sequence success

* fix: not removing token when logging out from OAuth

* fix: prettier

* refactor: using accessTokenId to delete

* chore: pr comments

* fix: test

* fix: test threshold
2023-02-05 23:31:16 -06:00
martyfuhry
16183791f3 feat(mobile): Removed stay logged in checkbox and made it enabled by default (#1550)
* removed stay logged in checkbox and made it enabled by default

* adds padding to login button

* removed all isSaveLogin

* fix: logout would re-login with previous credential upon app restart

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-02-05 08:07:02 -06:00
martyfuhry
f38c7a4b7e feat(mobile): Tap to enter immersive mode on gallery viewer (#1546) 2023-02-05 06:57:07 -06:00
Matthias Rupp
bb0b2a1f53 feat(mobile): Library page rework (album sorting, favorites) (#1501)
* Add album sorting

* Change AppBar to match photos page behaviour

* Add buttons

* First crude implementation of the favorites page

* Clean up

* Add favorite button

* i18n

* Add star indicator to thumbnail

* Add favorite logic to separate provider and fix favorite behavior in album

* Review feedback (Add isFavorite variable)

* dev: style buttons

* dev: styled drop down button

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-02-04 21:25:11 -06:00
Fynn Petersen-Frey
0048662182 refactor(mobile): reworked Asset, store all required fields from local & remote (#1539)
replace usage of AssetResponseDto with Asset

Add new class ExifInfo to store data from ExifResponseDto
2023-02-04 14:42:42 -06:00
Devin Buhl
7bd2455175 ci(docker): pass labels to push (#1545) 2023-02-03 14:22:00 -06:00
Alex
43e49f36b7 chore(release) post release tasks (#1543) 2023-02-03 14:21:49 -06:00
bo0tzz
e4b3479779 fix(ci): Run release version bump before mobile build (#1542)
* fix(ci): Run release version bump before mobile build

* feat(ci): Add ref input to build-mobile

* feat(ci): Pass new tag ref to build_mobile flow in prepare-release

* chore(ci): Naming
2023-02-03 12:39:56 -06:00
183 changed files with 4332 additions and 1828 deletions

View File

@@ -0,0 +1,24 @@
title: "[Feature] <feature-name-goes-here>"
labels: ["feature"]
body:
- type: markdown
attributes:
value: |
Please use this form to request new feature for Immich
- type: textarea
id: feature
attributes:
label: The feature
validations:
required: true
- type: checkboxes
validations:
required: true
attributes:
label: Platform
options:
- label: Server
- label: Web
- label: Mobile

View File

@@ -1,46 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG] <title>'
labels: bug, need triage
assignees: ''
---
<!--
Note: Please search to see if an issue already exists for the bug you encountered.
-->
**Describe the bug**
A clear and concise description of what the bug is.
**Task List**
*Please complete the task list below. We need this information to help us reproduce the bug or point out problems in your setup. You are not providing enough info may delay our effort to help you.*
- [ ] I have read thoroughly the README setup and installation instructions.
- [ ] I have included my `docker-compose` file.
- [ ] I have included my redacted `.env` file.
- [ ] I have included information on my machine, and environment.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**System**
- Phone OS [iOS, Android]: `<version>`
- Server Version: `<version>`
- Mobile App Version: `<version>`
**Additional context**
Add any other context about the problem here.

100
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: Report an issue with Immich
description: Report an issue with Immich
labels: ["bug", "need triage"]
title: "[BUG] <title>"
body:
- type: markdown
attributes:
value: |
This issue form is for reporting bugs only!
If you have a feature or enhancement request, please use the [feature request][fr] section of our [GitHub Discussions][fr].
[fr]: https://github.com/immich-app/immich/discussions/new?category=feature-request
- type: textarea
validations:
required: true
attributes:
label: The bug
description: >-
Describe the issue you are experiencing here, to communicate to the
maintainers. Tell us what you were trying to do and what happened.
Provide a clear and concise description of what the problem is.
- type: markdown
attributes:
value: |
## Environment
- type: input
validations:
required: true
attributes:
label: The OS that Immich Server is running on
placeholder: Ubuntu 22.10, Debian, Arch...etc
- type: input
id: version
validations:
required: true
attributes:
label: Version of Immich Server
placeholder: v1.0.0
- type: input
validations:
required: true
attributes:
label: Version of Immich Mobile App
placeholder: v1.0.0
- type: checkboxes
validations:
required: true
attributes:
label: Platform with the issue
options:
- label: Server
- label: Web
- label: Mobile
- type: textarea
validations:
required: true
attributes:
label: Your docker-compose.yml content
render: YAML
- type: textarea
validations:
required: true
attributes:
label: Your .env content
description: Please provide the redacted .env content of your setup
render: Shell
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
value: |
1.
2.
3.
...
render: bash
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: >
If you have any additional information for us, use the field below.
- type: markdown
attributes:
value: Thank you for submitting the form

View File

@@ -1 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: I have a question or need support
url: https://discord.gg/D8JsnBEuKb
about: We use GitHub for tracking bugs, please check out our Discord channel for freaky fast support.
- name: Feature Request
url: https://github.com/immich-app/immich/discussions/new?category=feature-request
about: Please use our GitHub Discussion for making feature requests.
- name: I'm unsure where to go
url: https://discord.gg/D8JsnBEuKb
about: If you are unsure where to go, then joining our Discord is recommended; Just ask!

View File

@@ -1,32 +0,0 @@
name: Feature Request
description: Request a feature that you would like for the app
title: "[Feature]: "
labels: ["feature", "need triage"]
assignees:
- ""
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request!
- type: textarea
id: feature-detail
attributes:
label: Feature detail
placeholder: Describe the feature you would like to see for the app
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Choose the platform for the feature
options:
- Web
- Mobile App
- Server
validations:
required: true

View File

@@ -3,6 +3,10 @@ name: Build Mobile
on:
workflow_dispatch:
workflow_call:
inputs:
ref:
required: false
type: string
pull_request:
push:
branches: [main]
@@ -13,7 +17,17 @@ jobs:
runs-on: macos-12
steps:
- name: Determine ref
id: get-ref
run: |
input_ref="${{ inputs.ref }}"
github_ref="${{ github.sha }}"
ref="${input_ref:-$github_ref}"
echo "ref=$ref" >> $GITHUB_OUTPUT
- uses: actions/checkout@v3
with:
ref: ${{ steps.get-ref.outputs.ref }}
- uses: actions/setup-java@v3
with:

View File

@@ -34,7 +34,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.4.0
uses: docker/setup-buildx-action@v2.4.1
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
@@ -98,3 +98,4 @@ jobs:
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}

View File

@@ -18,14 +18,12 @@ on:
type: boolean
jobs:
build_mobile:
uses: ./.github/workflows/build-mobile.yml
secrets: inherit
tag_release:
bump_version:
runs-on: ubuntu-latest
needs: build_mobile
outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -36,6 +34,7 @@ jobs:
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
- name: Commit and tag
id: push-tag
uses: EndBug/add-and-commit@v9
with:
author_name: Immich Release Bot
@@ -43,7 +42,19 @@ jobs:
message: "Version ${{ env.IMMICH_VERSION }}"
tag: ${{ env.IMMICH_VERSION }}
push: true
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: bump_version
secrets: inherit
with:
ref: ${{ needs.bump_version.outputs.ref }}
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
steps:
- name: Download APK
uses: actions/download-artifact@v3
with:

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,6 +5,9 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#f6f8fe</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,6 +5,9 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

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

View File

@@ -0,0 +1,4 @@
fixes back button while multiselecting showing the last selected image
fixes dark mode color for invite to album app bar
fixed local asset thumbnail size and eliminated fade in
Fullscreen image effects

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000299">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000307">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="25.91737">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="36.955598">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="38.549017">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="75.445063">
</testcase>

View File

@@ -108,23 +108,30 @@
"experimental_settings_new_asset_list_title": "Aktiver eksperimentelt fotogitter",
"experimental_settings_subtitle": "Brug på eget ansvar!",
"experimental_settings_title": "Eksperimentelle",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Tilføjede {added} billeder og videoer til album {album}. {failed} billeder og videoer er allerede i albummet.",
"home_page_add_to_album_success": "Tilføjede {added} billeder og videoer til album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Albummer",
"library_page_favorites": "Favorites",
"library_page_new_album": "Nyt album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Log ind",
"login_form_email_hint": "din-email@email.com",
"login_form_email_hint": "din-e-mail@e-mail.com",
"login_form_endpoint_hint": "http://din-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Angiv venligst http:// eller https://",
"login_form_err_invalid_email": "Ugyldig email",
"login_form_err_invalid_url": "Invalid URL",
"login_form_err_invalid_email": "Ugyldig e-mail",
"login_form_err_invalid_url": "Ugyldig webadresse",
"login_form_err_leading_whitespace": "Mellemrum før",
"login_form_err_trailing_whitespace": "Mellemrum efter",
"login_form_failed_get_oauth_server_config": "Fejl med at logge på med OAuth. Tjek serveres URL",
"login_form_failed_get_oauth_server_config": "Fejl med at logge på med OAuth. Tjek serveres webadresse",
"login_form_failed_get_oauth_server_disable": "OAuth er ikke tilgængelig på denne server",
"login_form_failed_login": "Der opstod en vejl ved at logge ind. Tjek server URL, email og kodeordet",
"login_form_label_email": "Email",
"login_form_failed_login": "Der opstod en vejl ved at logge ind. Tjek server webadressen, e-mailen og kodeordet",
"login_form_label_email": "E-mail",
"login_form_label_password": "Kodeord",
"login_form_password_hint": "kodeord",
"login_form_save_login": "Forbliv logget ind",

View File

@@ -108,10 +108,17 @@
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_title": "Experimentell",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Alben",
"library_page_favorites": "Favorites",
"library_page_new_album": "Neues Album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Anmelden",
"login_form_email_hint": "deine@email.de",
"login_form_endpoint_hint": "http://deine-server-ip:port/api",

View File

@@ -12,6 +12,10 @@
"album_viewer_appbar_share_leave": "Leave album",
"album_viewer_appbar_share_remove": "Remove from album",
"album_viewer_page_share_add_users": "Add users",
"asset_list_layout_settings_dynamic_layout_title": "Dynamic layout",
"asset_list_layout_settings_group_by": "Group assets by",
"asset_list_layout_settings_group_by_month_day": "Month + day",
"asset_list_layout_settings_group_by_month": "Month",
"asset_list_settings_subtitle": "Photo grid layout settings",
"asset_list_settings_title": "Photo Grid",
"backup_album_selection_page_albums_device": "Albums on device ({})",
@@ -108,12 +112,17 @@
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_title": "Experimental",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Albums",
"library_page_favorites": "Favorites",
"library_page_new_album": "New album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
@@ -194,4 +203,4 @@
"version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
}
}

View File

@@ -108,10 +108,17 @@
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_title": "Experimental",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Añadidos {added} elementos al álbum {album}. {failed} elementos ya están añadidos.",
"home_page_add_to_album_success": "Añadidos {added} elementos al álbum {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Albums",
"library_page_favorites": "Favorites",
"library_page_new_album": "New album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Iniciar Sesión",
"login_form_email_hint": "tucorreo@correo.com",
"login_form_endpoint_hint": "http://tu-ip-de-servidor:puerto/api",

View File

@@ -108,10 +108,17 @@
"experimental_settings_new_asset_list_title": "Ota käyttöön kokeellinen kuvaruudukko",
"experimental_settings_subtitle": "Käyttö omalla vastuulla!",
"experimental_settings_title": "Kokeellinen",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Lisätty {added} kohdetta albumiin {album}. {failed} kohdetta on jo albumissa.",
"home_page_add_to_album_success": "Lisätty {added} kohdetta albumiin {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Albumit",
"library_page_favorites": "Favorites",
"library_page_new_album": "Uusi albumi",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Kirjaudu",
"login_form_email_hint": "sahkopostisi@esimerkki.fi",
"login_form_endpoint_hint": "http://palvelimesi-osoite:portti/api",

View File

@@ -35,7 +35,7 @@
"backup_controller_page_background_battery_info_title": "Optimisation de la batterie",
"backup_controller_page_background_charging": "Seulement pendant la charge",
"backup_controller_page_background_configure_error": "Échec de la configuration du service d'arrière-plan",
"backup_controller_page_background_delay": "Delay new assets backup: {}",
"backup_controller_page_background_delay": "Retarder la sauvegarde des nouveaux éléments d'actif : {}",
"backup_controller_page_background_description": "Activez le service d'arrière-plan pour sauvegarder automatiquement tous les nouveaux éléments sans avoir à ouvrir l'application.",
"backup_controller_page_background_is_off": "La sauvegarde automatique en arrière-plan est désactivée",
"backup_controller_page_background_is_on": "La sauvegarde automatique en arrière-plan est activée",
@@ -83,10 +83,10 @@
"cache_settings_subtitle": "Contrôler le comportement de mise en cache de l'application mobile Immich",
"cache_settings_thumbnail_size": "Taille du cache des miniatures ({} éléments)",
"cache_settings_title": "Paramètres de mise en cache",
"control_bottom_app_bar_add_to_album": "Add to album",
"control_bottom_app_bar_album_info": "{} items",
"control_bottom_app_bar_album_info_shared": "{} items · Shared",
"control_bottom_app_bar_create_new_album": "Create new album",
"control_bottom_app_bar_add_to_album": "Ajouter à l'album",
"control_bottom_app_bar_album_info": "{} éléments",
"control_bottom_app_bar_album_info_shared": "{} éléments - Partagés",
"control_bottom_app_bar_create_new_album": "Créer un nouvel album",
"control_bottom_app_bar_delete": "Supprimer",
"control_bottom_app_bar_share": "Partager",
"create_album_page_untitled": "Sans titre",
@@ -108,28 +108,35 @@
"experimental_settings_new_asset_list_title": "Activez la grille de photos expérimentale",
"experimental_settings_subtitle": "Utilisez à vos dépends !",
"experimental_settings_title": "Expérimental",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "{added} éléments ajoutés à l'album {album}. Les éléments {failed} sont déjà dans l'album.",
"home_page_add_to_album_success": "{added} éléments ajoutés à l'album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Albums",
"library_page_favorites": "Favorites",
"library_page_new_album": "Nouvel album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Connexion",
"login_form_email_hint": "votreemail@email.com",
"login_form_endpoint_hint": "http://adresse-ip-serveur:port/api",
"login_form_endpoint_url": "URL du point d'accès au serveur",
"login_form_err_http": "Veuillez préciser http:// ou https://",
"login_form_err_invalid_email": "Email invalide",
"login_form_err_invalid_url": "Invalid URL",
"login_form_err_invalid_url": "URL invalide",
"login_form_err_leading_whitespace": "Espace en début de ligne",
"login_form_err_trailing_whitespace": "Espace de fin de ligne",
"login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL",
"login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server",
"login_form_failed_get_oauth_server_config": "Erreur de connexion par OAuth, vérifiez l\"URL du serveur",
"login_form_failed_get_oauth_server_disable": "La fonctionnalité OAuth n'est pas disponible sur ce serveur",
"login_form_failed_login": "Erreur de connexion, vérifiez l'url du serveur, l'email et le mot de passe",
"login_form_label_email": "Email",
"login_form_label_password": "Mot de passe",
"login_form_password_hint": "mot de passe",
"login_form_save_login": "Rester connecté",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_app_logs": "Logs",
"profile_drawer_app_logs": "Journaux",
"profile_drawer_client_server_up_to_date": "Le client et le serveur sont à jour",
"profile_drawer_settings": "Paramètres",
"profile_drawer_sign_out": "Se déconnecter",
@@ -142,17 +149,17 @@
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
"select_user_for_sharing_page_err_album": "Échec de la création de l'album",
"select_user_for_sharing_page_share_suggestions": "Suggestions",
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
"setting_image_viewer_original_title": "Load original image",
"setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.",
"setting_image_viewer_preview_title": "Load preview image",
"setting_image_viewer_help": "Le visualiseur de détails charge d'abord la petite miniature, puis l'aperçu de taille moyenne (s'il est activé), enfin l'original (s'il est activé).",
"setting_image_viewer_original_subtitle": "Activez cette option pour charger l'image originale en résolution originale (volumineux !). Désactiver pour réduire l'utilisation des données (réseau et cache de l'appareil).",
"setting_image_viewer_original_title": "Charger l'image originale",
"setting_image_viewer_preview_subtitle": "Activer pour charger une image de résolution moyenne. Désactiver pour charger directement l'original ou utiliser uniquement la miniature.",
"setting_image_viewer_preview_title": "Charger l'image d'aperçu",
"setting_notifications_notify_failures_grace_period": "Notifier les échecs de la sauvegarde en arrière-plan : {}",
"setting_notifications_notify_hours": "{} heures",
"setting_notifications_notify_immediately": "immédiatement",
"setting_notifications_notify_minutes": "{} minutes",
"setting_notifications_notify_never": "jamais",
"setting_notifications_notify_seconds": "{} seconds",
"setting_notifications_notify_seconds": "{} secondes",
"setting_notifications_single_progress_subtitle": "Informations détaillées sur la progression du transfert par élément",
"setting_notifications_single_progress_title": "Afficher la progression du détail de la sauvegarde en arrière-plan",
"setting_notifications_subtitle": "Ajustez vos préférences de notification",

View File

@@ -108,10 +108,17 @@
"experimental_settings_new_asset_list_title": "Attiva griglia di foto sperimentale",
"experimental_settings_subtitle": "Usalo a tuo rischio!",
"experimental_settings_title": "Sperimentale",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Aggiunti {added} elementi all'album {album}. {failed} elementi erano già presenti nell'album.",
"home_page_add_to_album_success": "Aggiunti {added} elementi all'album {album}",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Album",
"library_page_favorites": "Favorites",
"library_page_new_album": "Nuovo Album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Login",
"login_form_email_hint": "tuaemail@email.com",
"login_form_endpoint_hint": "http://ip-del-tuo-server:port/api",

View File

@@ -108,10 +108,17 @@
"experimental_settings_new_asset_list_title": "試験的なグリッドを有効",
"experimental_settings_subtitle": "試験的だから自己責任でね",
"experimental_settings_title": "試験的",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "アルバム",
"library_page_favorites": "Favorites",
"library_page_new_album": "新しいアルバム",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "ログイン",
"login_form_email_hint": "example@email.com",
"login_form_endpoint_hint": "https://example.com:port/api",

View File

@@ -1,6 +1,9 @@
{
"album_info_card_backup_album_excluded": "UITGESLOTEN",
"album_info_card_backup_album_included": "INGESLOTEN",
"album_thumbnail_card_item": "1 item",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Gedeeld",
"album_viewer_appbar_share_delete": "Verwijder album",
"album_viewer_appbar_share_err_delete": "Fout bij verwijderen album",
"album_viewer_appbar_share_err_leave": "Fout bij verlaten album",
@@ -9,6 +12,8 @@
"album_viewer_appbar_share_leave": "Verlaat album",
"album_viewer_appbar_share_remove": "Verwijder uit album",
"album_viewer_page_share_add_users": "Voeg gebruiker toe",
"asset_list_settings_subtitle": "Foto grid layout instellingen",
"asset_list_settings_title": "Foto Grid",
"backup_album_selection_page_albums_device": "Albums op apparaat ({})",
"backup_album_selection_page_albums_tap": "Tik om in te voegen, dubbel tik om uit te sluiten",
"backup_album_selection_page_assets_scatter": "Items kunnen over verschillende albums verdeeld zijn, dus albums kunnen ingesloten of uitgesloten zijn van het backup proces.",
@@ -16,137 +21,182 @@
"backup_album_selection_page_selection_info": "Selectie info",
"backup_album_selection_page_total_assets": "Totaal unieke items",
"backup_all": "Alle",
"backup_background_service_default_notification": "Controleren op nieuw items…",
"backup_background_service_upload_failure_notification": "Fout bij upload {}",
"backup_background_service_in_progress_notification": "Backuppen van items…",
"backup_background_service_current_upload_notification": "Uploaden {}",
"backup_background_service_error_title": "Backup fout",
"backup_background_service_connection_failed_message": "Fout bij verbinden server. Opnieuw proberen…",
"backup_background_service_backup_failed_message": "Fout bij backuppen items. Opnieuw proberen…",
"backup_controller_page_albums": "Backup Albums",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Geselecteerd: ",
"backup_controller_page_backup_sub": "Foto's en video's gebackupped",
"backup_controller_page_background_description": "Gebruik achtergrondservice om automatisch nieuwe items te uploaden naar server zonder de app te openen",
"backup_controller_page_background_wifi": "Alleen op WiFi",
"backup_background_service_connection_failed_message": "Fout bij verbinden server. Opnieuw proberen…",
"backup_background_service_current_upload_notification": "Uploaden {}",
"backup_background_service_default_notification": "Controleren op nieuw items…",
"backup_background_service_error_title": "Backup fout",
"backup_background_service_in_progress_notification": "Back-up maken van items…",
"backup_background_service_upload_failure_notification": "Fout bij upload {}",
"backup_controller_page_albums": "Back-up albums",
"backup_controller_page_background_battery_info_link": "Toon me hoe",
"backup_controller_page_background_battery_info_message": "Schakel voor de beste back-up ervaring op de achtergrond alle batterij optimalisaties uit, die de achtergrondactiviteit van Immich beperkt.\n\nAangezien dit apparaatspecifiek is, zoek de vereiste informatie op voor de fabrikant van je apparaat.",
"backup_controller_page_background_battery_info_ok": "OK",
"backup_controller_page_background_battery_info_title": "Batterij optimalisaties",
"backup_controller_page_background_charging": "Alleen tijdens opladen",
"backup_controller_page_background_is_on": "Automatische achtergrond backup staat aan",
"backup_controller_page_background_is_off": "Automatische achtergrond backup staat uit",
"backup_controller_page_background_turn_on": "Zet achtergrondservice aan",
"backup_controller_page_background_turn_off": "Zet achtergrondservice uit",
"backup_controller_page_background_configure_error": "Achtergrondservice configuratie mislukt",
"backup_controller_page_background_delay": "Back-up vertraging nieuwe items: {}",
"backup_controller_page_background_description": "Schakel de achtergrondservice in om automatisch een back-up te maken van nieuwe items zonder de app te hoeven openen",
"backup_controller_page_background_is_off": "Automatische achtergrond back-up staat uit",
"backup_controller_page_background_is_on": "Automatische achtergrond back-up staat aan",
"backup_controller_page_background_turn_off": "Zet achtergrondservice uit",
"backup_controller_page_background_turn_on": "Zet achtergrondservice aan",
"backup_controller_page_background_wifi": "Alleen op WiFi",
"backup_controller_page_backup": "Back-up",
"backup_controller_page_backup_selected": "Geselecteerd: ",
"backup_controller_page_backup_sub": "Geback-upte foto's en video's",
"backup_controller_page_cancel": "Annuleren",
"backup_controller_page_created": "Gemaakt op: {}",
"backup_controller_page_desc_backup": "Configureer backup om automatisch nieuwe items te uploaden naar server.",
"backup_controller_page_desc_backup": "Schakel back-up op de voorgrond in om automatisch nieuwe items naar de server te uploaden bij het openen van de app.",
"backup_controller_page_excluded": "Uitgezonderd: ",
"backup_controller_page_failed": "Mislukt ({})",
"backup_controller_page_filename": "Bestandsnaam: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Backup informatie",
"backup_controller_page_info": "Back-up informatie",
"backup_controller_page_none_selected": "Geen geselecteerd",
"backup_controller_page_remainder": "Rest",
"backup_controller_page_remainder_sub": "Overgebleven foto's en video's om te backuppen uit selectie",
"backup_controller_page_remainder": "Resterend",
"backup_controller_page_remainder_sub": "Resterende foto's en video's om een back-up van te maken uit selectie",
"backup_controller_page_select": "Selecteer",
"backup_controller_page_server_storage": "Server Opslag",
"backup_controller_page_start_backup": "Start Backup",
"backup_controller_page_status_off": "Backup staat uit",
"backup_controller_page_status_on": "Backup staat aan",
"backup_controller_page_server_storage": "Server opslag",
"backup_controller_page_start_backup": "Back-up uitvoeren",
"backup_controller_page_status_off": "Automatische back-up op de voorgrond staat uit",
"backup_controller_page_status_on": "Automatische back-up op de voorgrond staat aan",
"backup_controller_page_storage_format": "{} van {} gebruikt",
"backup_controller_page_to_backup": "Albums om te backuppen",
"backup_controller_page_to_backup": "Albums om een back-up van te maken",
"backup_controller_page_total": "Totaal",
"backup_controller_page_total_sub": "Alle unieke foto's en video's uit geselecteerde albums",
"backup_controller_page_turn_off": "Backup uitzetten",
"backup_controller_page_turn_on": "Backup aanzetten",
"backup_controller_page_turn_off": "Zet back-up op de voorgrond uit",
"backup_controller_page_turn_on": "Zet back-up op de voorgrond aan",
"backup_controller_page_uploading_file_info": "Bestandsgegevens uploaden",
"backup_err_only_album": "Kan niet alleen het album verwijderen",
"backup_err_only_album": "Kan het enige album niet verwijderen",
"backup_info_card_assets": "items",
"cache_settings_album_thumbnails": "Thumbnails bibliotheekpagina ({} items)",
"cache_settings_clear_cache_button": "Cache wissen",
"cache_settings_clear_cache_button_title": "Wist de cache van de app. Dit zal de presentaties van de app aanzienlijk beïnvloeden totdat de cache opnieuw is opgebouwd.",
"cache_settings_image_cache_size": "Grootte afbeeldingscache ({} items)",
"cache_settings_statistics_album": "Bibliotheek thumbnails",
"cache_settings_statistics_assets": "{} items ({})",
"cache_settings_statistics_full": "Volledige afbeeldingen",
"cache_settings_statistics_shared": "Gedeeld album thumbnails",
"cache_settings_statistics_thumbnail": "Thumbnails",
"cache_settings_statistics_title": "Cachegebruik",
"cache_settings_subtitle": "Beheer het cachegedrag van de Immich app",
"cache_settings_thumbnail_size": "Thumbnail cachegrootte ({} items)",
"cache_settings_title": "Cache instellingen",
"control_bottom_app_bar_add_to_album": "Toevoegen aan album",
"control_bottom_app_bar_album_info": "{} items",
"control_bottom_app_bar_album_info_shared": "{} items · Gedeeld",
"control_bottom_app_bar_create_new_album": "Maak nieuw album",
"control_bottom_app_bar_delete": "Verwijderen",
"create_shared_album_page_share": "Delen",
"control_bottom_app_bar_share": "Delen",
"create_album_page_untitled": "Naamloos",
"create_shared_album_page_create": "Aanmaken",
"create_shared_album_page_share_add_assets": "VOEG FOTO'S TOE",
"create_shared_album_page_share_select_photos": "Selecteer Foto's",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"date_format": "E, LLL d, y • h:mm a",
"create_shared_album_page_share": "Delen",
"create_shared_album_page_share_add_assets": "ITEMS TOEVOEGEN",
"create_shared_album_page_share_select_photos": "Selecteer foto's",
"daily_title_text_date": "E dd MMM",
"daily_title_text_date_year": "E dd MMM yyyy",
"date_format": "E d LLL y • H:mm",
"delete_dialog_alert": "Deze items zullen permanent verwijderd worden van Immich en je apparaat",
"delete_dialog_cancel": "Annuleren",
"delete_dialog_ok": "Verwijderen",
"delete_dialog_title": "Verwijder permanent",
"exif_bottom_sheet_description": "Voeg beschrijving toe...",
"delete_dialog_title": "Permanent verwijderen",
"exif_bottom_sheet_description": "Beschrijving toevoegen...",
"exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "LOCATIE",
"login_form_button_text": "Login",
"experimental_settings_new_asset_list_subtitle": "Werk in uitvoering",
"experimental_settings_new_asset_list_title": "Experimenteel foto grid inschakelen",
"experimental_settings_subtitle": "Gebruik op eigen risico!",
"experimental_settings_title": "Experimenteel",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "{added} items toegevoegd aan album {album}. {failed} items staan al in het album.",
"home_page_add_to_album_success": "{added} items toegevoegd aan album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Albums",
"library_page_favorites": "Favorites",
"library_page_new_album": "Nieuw album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Inloggen",
"login_form_email_hint": "jouwemail@email.com",
"login_form_endpoint_hint": "http://jouw-server-ip:port/api",
"login_form_endpoint_url": "Server URL",
"login_form_err_http": "Voer http:// of https:// in",
"login_form_err_invalid_email": "Ongeldige Email",
"login_form_err_invalid_email": "Ongeldig e-mailadres",
"login_form_err_invalid_url": "Ongeldige URL",
"login_form_err_leading_whitespace": "Spatie aan het begin",
"login_form_err_trailing_whitespace": "Spatie aan het eind",
"login_form_failed_login": "Fout bij inloggen, controleer server url, email en wachtwoord",
"login_form_label_email": "Email",
"login_form_failed_get_oauth_server_config": "Fout bij inloggen met OAuth, controleer server URL",
"login_form_failed_get_oauth_server_disable": "OAuth functie is niet beschikbaar op deze server",
"login_form_failed_login": "Fout bij inloggen, controleer server URL, e-mailadres en wachtwoord",
"login_form_label_email": "E-mailadres",
"login_form_label_password": "Wachtwoord",
"login_form_password_hint": "wachtwoord",
"login_form_save_login": "Ingelogd blijven",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Client en Server zijn up-to-date",
"profile_drawer_sign_out": "Uitloggen",
"profile_drawer_app_logs": "Logboek",
"profile_drawer_client_server_up_to_date": "Client en server zijn up-to-date",
"profile_drawer_settings": "Instellingen",
"search_bar_hint": "Zoek je foto's",
"profile_drawer_sign_out": "Uitloggen",
"search_bar_hint": "Zoeken naar foto's",
"search_page_no_objects": "Geen object gegevens beschikbaar",
"search_page_no_places": "Geen locatie gegevens beschikbaar",
"search_page_places": "Plaatsen",
"search_page_things": "Dingen",
"search_result_page_new_search_hint": "Nieuw resultaat",
"search_result_page_new_search_hint": "Nieuwe zoekopdracht",
"select_additional_user_for_sharing_page_suggestions": "Suggesties",
"select_user_for_sharing_page_err_album": "Album aanmaken mislukt",
"select_user_for_sharing_page_share_suggestions": "Suggesties",
"setting_image_viewer_help": "De gedetailleerde weergave laadt eerst de kleine thumbnail, vervolgens het middelgrote voorbeeld (indien ingeschakeld) en ten slotte het origineel (indien ingeschakeld).",
"setting_image_viewer_original_subtitle": "Inschakelen om de originele afbeelding met volledige resolutie (groot!) te laden. Uitschakelen om datagebruik te verminderen (zowel netwerk- als apparaatcache).",
"setting_image_viewer_original_title": "Originele afbeelding laden",
"setting_image_viewer_preview_subtitle": "Schakel in om een afbeelding met middelgrote resolutie te laden. Schakel uit om alleen het origineel direct te laden of alleen de thumbnail te gebruiken.",
"setting_image_viewer_preview_title": "Voorbeeldafbeelding laden",
"setting_notifications_notify_failures_grace_period": "Meld back-upfouten op de achtergrond: {}",
"setting_notifications_notify_hours": "{} uur",
"setting_notifications_notify_immediately": "meteen",
"setting_notifications_notify_minutes": "{} minuten",
"setting_notifications_notify_never": "nooit",
"setting_notifications_notify_seconds": "{} seconden",
"setting_notifications_single_progress_subtitle": "Gedetaileerde informatie over de uploadvoortgang per item",
"setting_notifications_single_progress_title": "Toon gedetailleerde informatie over back-ups op de achtergrond",
"setting_notifications_subtitle": "Werk je notificatievoorkeuren bij",
"setting_notifications_title": "Notificaties",
"setting_notifications_total_progress_subtitle": "Algehele uploadvoortgang (voltooid/totaal aantal items)",
"setting_notifications_total_progress_title": "Toon de totale voortgang van achtergrond back-up",
"setting_pages_app_bar_settings": "Instellingen",
"settings_require_restart": "Start Immich opnieuw op om deze instelling toe te passen",
"share_add": "Toevoegen",
"share_add_photos": "Foto's toevoegen",
"share_add_title": "Titel toevoegen",
"share_create_album": "Album aanmaken",
"share_dialog_preparing": "Voorbereiden...",
"share_invite": "Uitnodigen voor album",
"sharing_page_album": "Gedeelde albums",
"sharing_page_description": "Maak gedeelde albums om foto's en video's te delen met mensen in je netwerk.",
"sharing_page_empty_list": "LEGE LIJST",
"sharing_silver_appbar_create_shared_album": "Maak gedeeld album",
"sharing_silver_appbar_share_partner": "Delen met partner",
"tab_controller_nav_library": "Bibliotheek",
"tab_controller_nav_photos": "Foto's",
"tab_controller_nav_search": "Zoeken",
"tab_controller_nav_sharing": "Delen",
"tab_controller_nav_library": "Bibliotheek",
"theme_setting_asset_list_storage_indicator_title": "Laat ruimte indicator zien bij item tegels",
"theme_setting_asset_list_tiles_per_row_title": "Aantal items per rij ({})",
"theme_setting_dark_mode_switch": "Donkere modus",
"theme_setting_image_viewer_quality_subtitle": "Pas de kwaliteit aan van de gedetailleerde foto weergave",
"theme_setting_image_viewer_quality_title": "Foto weergave kwaliteit",
"theme_setting_system_theme_switch": "Automatisch (volg systeeminstelling)",
"theme_setting_theme_subtitle": "Kies de thema instelling van de app",
"theme_setting_theme_title": "Thema",
"theme_setting_three_stage_loading_subtitle": "Laden in drie fasen kan de laadprestaties verbeteren, maar veroorzaakt een aanzienlijk hogere netwerkbelasting",
"theme_setting_three_stage_loading_title": "Schakel laden in drie fasen in",
"version_announcement_overlay_ack": "Bevestig",
"version_announcement_overlay_release_notes": "release opmerkingen",
"version_announcement_overlay_text_1": "Er is een nieuwe versie beschikbaar van",
"version_announcement_overlay_text_2": "neem je tijd en bezoek de ",
"version_announcement_overlay_text_3": " controleer of je docker-compose en .env up-to-date zijn om te voorkomen dat er misconfiguraties zijn, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je server automatisch configureert.",
"version_announcement_overlay_title": "Nieuwe server versie beschikbaar \uD83C\uDF89",
"album_thumbnail_card_item": "1 item",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Gedeeld",
"library_page_albums": "Albums",
"library_page_new_album": "Nieuw album",
"create_album_page_untitled": "Naamloos",
"share_dialog_preparing": "Voorbereiden...",
"control_bottom_app_bar_share": "Delen",
"setting_pages_app_bar_settings": "Instellingen",
"theme_setting_theme_title": "Thema",
"theme_setting_theme_subtitle": "Kies de thema instelling van de app",
"theme_setting_system_theme_switch": "Automatisch (volg systeeminstelling)",
"theme_setting_dark_mode_switch": "Donkere modus",
"theme_setting_image_viewer_quality_title": "Foto weergave kwaliteit",
"theme_setting_image_viewer_quality_subtitle": "Pas de kwaliteit aan van de gedetailleerde foto weergave",
"theme_setting_three_stage_loading_title": "Drie-laags laden inschakelen",
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
"asset_list_settings_title": "Foto Grid",
"asset_list_settings_subtitle": "Foto grid layout instellingen",
"theme_setting_asset_list_storage_indicator_title": "Laat ruimte indicator zien bij item tegels",
"theme_setting_asset_list_tiles_per_row_title": "Aantal items per rij ({})",
"setting_notifications_title": "Notificaties",
"setting_notifications_subtitle": "Werk je notificatievoorkeuren bij",
"setting_notifications_notify_failures_grace_period": "Melding achtergrond backup fouten: {}",
"setting_notifications_notify_immediately": "meteen",
"setting_notifications_notify_minutes": "{} minuten",
"setting_notifications_notify_hours": "{} uur",
"setting_notifications_notify_never": "nooit"
}
"version_announcement_overlay_text_3": " controleer of je docker-compose en .env up-to-date zijn om te voorkomen dat er misconfiguraties zijn, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je serverapplicatie automatisch bijwerkt.",
"version_announcement_overlay_title": "Nieuwe serverversie beschikbaar \uD83C\uDF89"
}

View File

@@ -108,10 +108,17 @@
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_title": "Experimental",
"favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Albumy",
"library_page_favorites": "Favorites",
"library_page_new_album": "Nowy album",
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"login_form_button_text": "Login",
"login_form_email_hint": "twojmail@email.com",
"login_form_endpoint_hint": "http://ip-twojego-serwera:port/api",

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,138 @@
flutter_native_splash:
# This package generates native code to customize Flutter's default white native splash screen
# with background color and splash image.
# Customize the parameters below, and run the following command in the terminal:
# flutter pub run flutter_native_splash:create
# To restore Flutter's default white splash screen, run the following command in the terminal:
# flutter pub run flutter_native_splash:remove
# color or background_image is the only required parameter. Use color to set the background
# of your splash screen to a solid color. Use background_image to set the background of your
# splash screen to a png image. This is useful for gradients. The image will be stretch to the
# size of the app. Only one parameter can be used, color and background_image cannot both be set.
#background_image: "assets/immich-logo-no-outline.png"
color: "#f6f8fe"
# Optional parameters are listed below. To enable a parameter, uncomment the line by removing
# the leading # character.
# The image parameter allows you to specify an image used in the splash screen. It must be a
# png file and should be sized for 4x pixel density.
image: assets/immich-splash.png
# The branding property allows you to specify an image used as branding in the splash screen.
# It must be a png file. It is supported for Android, iOS and the Web. For Android 12,
# see the Android 12 section below.
#branding: assets/dart.png
# To position the branding image at the bottom of the screen you can use bottom, bottomRight,
# and bottomLeft. The default values is bottom if not specified or specified something else.
#branding_mode: bottom
# The color_dark, background_image_dark, image_dark, branding_dark are parameters that set the background
# and image when the device is in dark mode. If they are not specified, the app will use the
# parameters from above. If the image_dark parameter is specified, color_dark or
# background_image_dark must be specified. color_dark and background_image_dark cannot both be
# set.
color_dark: "#000000"
#background_image_dark: "assets/dark-background.png"
#image_dark: assets/splash-invert.png
#branding_dark: assets/dart_dark.png
# Android 12 handles the splash screen differently than previous versions. Please visit
# https://developer.android.com/guide/topics/ui/splash-screen
# Following are Android 12 specific parameter.
android_12:
# The image parameter sets the splash screen icon image. If this parameter is not specified,
# the app's launcher icon will be used instead.
# Please note that the splash screen will be clipped to a circle on the center of the screen.
# App icon with an icon background: This should be 960×960 pixels, and fit within a circle
# 640 pixels in diameter.
# App icon without an icon background: This should be 1152×1152 pixels, and fit within a circle
# 768 pixels in diameter.
image: assets/immich-splash-android12.png
# Splash screen background color.
#color: "#42a5f5"
# App icon background color.
#icon_background_color: "#111111"
# The branding property allows you to specify an image used as branding in the splash screen.
#branding: assets/dart.png
# The image_dark, color_dark, icon_background_color_dark, and branding_dark set values that
# apply when the device is in dark mode. If they are not specified, the app will use the
# parameters from above.
#image_dark: assets/android12splash-invert.png
#color_dark: "#042a49"
#icon_background_color_dark: "#eeeeee"
# The android, ios and web parameters can be used to disable generating a splash screen on a given
# platform.
#android: false
#ios: false
#web: false
# Platform specific images can be specified with the following parameters, which will override
# the respective parameter. You may specify all, selected, or none of these parameters:
#color_android: "#42a5f5"
#color_dark_android: "#042a49"
#color_ios: "#42a5f5"
#color_dark_ios: "#042a49"
#color_web: "#42a5f5"
#color_dark_web: "#042a49"
#image_android: assets/splash-android.png
#image_dark_android: assets/splash-invert-android.png
#image_ios: assets/splash-ios.png
#image_dark_ios: assets/splash-invert-ios.png
#image_web: assets/splash-web.png
#image_dark_web: assets/splash-invert-web.png
#background_image_android: "assets/background-android.png"
#background_image_dark_android: "assets/dark-background-android.png"
#background_image_ios: "assets/background-ios.png"
#background_image_dark_ios: "assets/dark-background-ios.png"
#background_image_web: "assets/background-web.png"
#background_image_dark_web: "assets/dark-background-web.png"
#branding_android: assets/brand-android.png
#branding_dark_android: assets/dart_dark-android.png
#branding_ios: assets/brand-ios.png
#branding_dark_ios: assets/dart_dark-ios.png
# The position of the splash image can be set with android_gravity, ios_content_mode, and
# web_image_mode parameters. All default to center.
#
# android_gravity can be one of the following Android Gravity (see
# https://developer.android.com/reference/android/view/Gravity): bottom, center,
# center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal,
# fill_vertical, left, right, start, or top.
#android_gravity: center
#
# ios_content_mode can be one of the following iOS UIView.ContentMode (see
# https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill,
# scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight,
# bottomLeft, or bottomRight.
#ios_content_mode: center
#
# web_image_mode can be one of the following modes: center, contain, stretch, and cover.
#web_image_mode: center
# The screen orientation can be set in Android with the android_screen_orientation parameter.
# Valid parameters can be found here:
# https://developer.android.com/guide/topics/manifest/activity-element#screen
#android_screen_orientation: sensorLandscape
# To hide the notification bar, use the fullscreen parameter. Has no effect in web since web
# has no notification bar. Defaults to false.
# NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads.
# To show the notification bar, add the following code to your Flutter app:
# WidgetsFlutterBinding.ensureInitialized();
# SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]);
#fullscreen: true
# If you have changed the name(s) of your info.plist file(s), you can specify the filename(s)
# with the info_plist_files parameter. Remove only the # characters in the three lines below,
# do not remove any spaces:
#info_plist_files:
# - 'ios/Runner/Info-Debug.plist'
# - 'ios/Runner/Info-Release.plist'

View File

@@ -1,7 +1,9 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:integration_test/integration_test.dart';
import 'package:isar/isar.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
import 'package:immich_mobile/main.dart' as app;
@@ -34,8 +36,12 @@ class ImmichTestHelper {
// Clear all data from Hive
await Hive.deleteFromDisk();
await app.openBoxes();
// Clear all data from Isar (reuse existing instance if available)
final db = Isar.getInstance() ?? await app.loadDb();
await Store.clear();
await db.writeTxn(() => db.clear());
// Load main Widget
await tester.pumpWidget(app.getMainWidget());
await tester.pumpWidget(app.getMainWidget(db));
// Post run tasks
await tester.pumpAndSettle();
await EasyLocalization.ensureInitialized();

View File

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

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="320" height="320"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

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

View File

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

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000424">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.833772">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.33276">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="24.229726">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.370222">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.663451">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.977555">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="90.5773">
<testcase classname="fastlane.lanes" name="4: build_app" time="94.172587">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="73.273138">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="82.579096">
</testcase>

View File

@@ -5,6 +5,7 @@ const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
const String userIdKey = 'immichUserIdKey'; // Key 6
// Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box

View File

@@ -17,8 +17,10 @@ import 'package:immich_mobile/modules/login/providers/authentication.provider.da
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/release_info.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
@@ -26,11 +28,16 @@ import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'constants/hive_box.dart';
void main() async {
await initApp();
runApp(getMainWidget());
final db = await loadDb();
await migrateHiveToStoreIfNecessary();
runApp(getMainWidget(db));
}
Future<void> openBoxes() async {
@@ -70,13 +77,27 @@ Future<void> initApp() async {
ImmichLogger().init();
}
Widget getMainWidget() {
Future<Isar> loadDb() async {
final dir = await getApplicationDocumentsDirectory();
Isar db = await Isar.open(
[StoreValueSchema],
directory: dir.path,
maxSizeMiB: 256,
);
Store.init(db);
return db;
}
Widget getMainWidget(Isar db) {
return EasyLocalization(
supportedLocales: locales,
path: translationsPath,
useFallbackTranslations: true,
fallbackLocale: locales.first,
child: const ProviderScope(child: ImmichApp()),
child: ProviderScope(
overrides: [dbProvider.overrideWithValue(db)],
child: const ImmichApp(),
),
);
}

View File

@@ -2,24 +2,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
final AlbumService _albumService;
final AlbumCacheService _albumCacheService;
_cacheState() {
void _cacheState() {
_albumCacheService.put(state);
}
getAllAlbums() async {
Future<void> getAllAlbums() async {
if (await _albumCacheService.isValid() && state.isEmpty) {
state = await _albumCacheService.get();
final albums = await _albumCacheService.get();
if (albums != null) {
state = albums;
}
}
List<AlbumResponseDto>? albums =
await _albumService.getAlbums(isShared: false);
final albums = await _albumService.getAlbums(isShared: false);
if (albums != null) {
state = albums;
@@ -27,17 +29,16 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}
}
deleteAlbum(String albumId) {
state = state.where((album) => album.id != albumId).toList();
void deleteAlbum(Album album) {
state = state.where((a) => a.id != album.id).toList();
_cacheState();
}
Future<AlbumResponseDto?> createAlbum(
Future<Album?> createAlbum(
String albumTitle,
Set<Asset> assets,
) async {
AlbumResponseDto? album =
await _albumService.createAlbum(albumTitle, assets, []);
Album? album = await _albumService.createAlbum(albumTitle, assets, []);
if (album != null) {
state = [...state, album];
@@ -49,8 +50,7 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}
}
final albumProvider =
StateNotifierProvider<AlbumNotifier, List<AlbumResponseDto>>((ref) {
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(albumCacheServiceProvider),

View File

@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
AlbumViewerNotifier(this.ref)
@@ -30,14 +31,12 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
}
Future<bool> changeAlbumTitle(
String albumId,
String ownerId,
Album album,
String newAlbumTitle,
) async {
AlbumService service = ref.watch(albumServiceProvider);
bool isSuccess =
await service.changeTitleAlbum(albumId, ownerId, newAlbumTitle);
bool isSuccess = await service.changeTitleAlbum(album, newAlbumTitle);
if (isSuccess) {
state = state.copyWith(editTitleText: "", isEditAlbum: false);

View File

@@ -2,30 +2,31 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/user.dart';
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, this._sharedAlbumCacheService)
: super([]);
final AlbumService _albumService;
final SharedAlbumCacheService _sharedAlbumCacheService;
_cacheState() {
void _cacheState() {
_sharedAlbumCacheService.put(state);
}
Future<AlbumResponseDto?> createSharedAlbum(
Future<Album?> createSharedAlbum(
String albumName,
Set<Asset> assets,
List<String> sharedUserIds,
Iterable<Asset> assets,
Iterable<User> sharedUsers,
) async {
try {
var newAlbum = await _albumService.createAlbum(
albumName,
assets,
sharedUserIds,
sharedUsers,
);
if (newAlbum != null) {
@@ -41,13 +42,15 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}
}
getAllSharedAlbums() async {
Future<void> getAllSharedAlbums() async {
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
state = await _sharedAlbumCacheService.get();
final albums = await _sharedAlbumCacheService.get();
if (albums != null) {
state = albums;
}
}
List<AlbumResponseDto>? sharedAlbums =
await _albumService.getAlbums(isShared: true);
List<Album>? sharedAlbums = await _albumService.getAlbums(isShared: true);
if (sharedAlbums != null) {
state = sharedAlbums;
@@ -55,16 +58,16 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}
}
deleteAlbum(String albumId) async {
state = state.where((album) => album.id != albumId).toList();
void deleteAlbum(Album album) {
state = state.where((a) => a.id != album.id).toList();
_cacheState();
}
Future<bool> leaveAlbum(String albumId) async {
var res = await _albumService.leaveAlbum(albumId);
Future<bool> leaveAlbum(Album album) async {
var res = await _albumService.leaveAlbum(album);
if (res) {
state = state.where((album) => album.id != albumId).toList();
state = state.where((a) => a.id != album.id).toList();
_cacheState();
return true;
} else {
@@ -73,10 +76,10 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}
Future<bool> removeAssetFromAlbum(
String albumId,
List<String> assetIds,
Album album,
Iterable<Asset> assets,
) async {
var res = await _albumService.removeAssetFromAlbum(albumId, assetIds);
var res = await _albumService.removeAssetFromAlbum(album, assets);
if (res) {
return true;
@@ -87,15 +90,15 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}
final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<AlbumResponseDto>>((ref) {
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});
final sharedAlbumDetailProvider = FutureProvider.autoDispose
.family<AlbumResponseDto?, String>((ref, albumId) async {
final sharedAlbumDetailProvider =
FutureProvider.autoDispose.family<Album?, String>((ref, albumId) async {
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
return await sharedAlbumService.getAlbumDetail(albumId);

View File

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

View File

@@ -2,7 +2,9 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
@@ -18,29 +20,31 @@ class AlbumService {
AlbumService(this._apiService);
Future<List<AlbumResponseDto>?> getAlbums({required bool isShared}) async {
Future<List<Album>?> getAlbums({required bool isShared}) async {
try {
return await _apiService.albumApi
final dto = await _apiService.albumApi
.getAllAlbums(shared: isShared ? isShared : null);
return dto?.map(Album.remote).toList();
} catch (e) {
debugPrint("Error getAllSharedAlbum ${e.toString()}");
return null;
}
}
Future<AlbumResponseDto?> createAlbum(
Future<Album?> createAlbum(
String albumName,
Iterable<Asset> assets,
List<String> sharedUserIds,
) async {
Iterable<Asset> assets, [
Iterable<User> sharedUsers = const [],
]) async {
try {
return await _apiService.albumApi.createAlbum(
final dto = await _apiService.albumApi.createAlbum(
CreateAlbumDto(
albumName: albumName,
assetIds: assets.map((asset) => asset.id).toList(),
sharedWithUserIds: sharedUserIds,
assetIds: assets.map((asset) => asset.remoteId!).toList(),
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
),
);
return dto != null ? Album.remote(dto) : null;
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
return null;
@@ -50,14 +54,14 @@ class AlbumService {
/*
* Creates names like Untitled, Untitled (1), Untitled (2), ...
*/
String _getNextAlbumName(List<AlbumResponseDto>? albums) {
String _getNextAlbumName(List<Album>? albums) {
const baseName = "Untitled";
if (albums != null) {
for (int round = 0; round < albums.length; round++) {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (albums.where((a) => a.albumName == proposedName).isEmpty) {
if (albums.where((a) => a.name == proposedName).isEmpty) {
return proposedName;
}
}
@@ -65,7 +69,7 @@ class AlbumService {
return baseName;
}
Future<AlbumResponseDto?> createAlbumWithGeneratedName(
Future<Album?> createAlbumWithGeneratedName(
Iterable<Asset> assets,
) async {
return createAlbum(
@@ -75,9 +79,10 @@ class AlbumService {
);
}
Future<AlbumResponseDto?> getAlbumDetail(String albumId) async {
Future<Album?> getAlbumDetail(String albumId) async {
try {
return await _apiService.albumApi.getAlbumInfo(albumId);
final dto = await _apiService.albumApi.getAlbumInfo(albumId);
return dto != null ? Album.remote(dto) : null;
} catch (e) {
debugPrint('Error [getAlbumDetail] ${e.toString()}');
return null;
@@ -86,12 +91,12 @@ class AlbumService {
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
Iterable<Asset> assets,
String albumId,
Album album,
) async {
try {
var result = await _apiService.albumApi.addAssetsToAlbum(
albumId,
AddAssetsDto(assetIds: assets.map((asset) => asset.id).toList()),
album.remoteId!,
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
);
return result;
} catch (e) {
@@ -102,11 +107,11 @@ class AlbumService {
Future<bool> addAdditionalUserToAlbum(
List<String> sharedUserIds,
String albumId,
Album album,
) async {
try {
var result = await _apiService.albumApi.addUsersToAlbum(
albumId,
album.remoteId!,
AddUsersDto(sharedUserIds: sharedUserIds),
);
@@ -117,9 +122,9 @@ class AlbumService {
}
}
Future<bool> deleteAlbum(String albumId) async {
Future<bool> deleteAlbum(Album album) async {
try {
await _apiService.albumApi.deleteAlbum(albumId);
await _apiService.albumApi.deleteAlbum(album.remoteId!);
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
@@ -127,10 +132,9 @@ class AlbumService {
}
}
Future<bool> leaveAlbum(String albumId) async {
Future<bool> leaveAlbum(Album album) async {
try {
await _apiService.albumApi.removeUserFromAlbum(albumId, "me");
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
@@ -139,13 +143,15 @@ class AlbumService {
}
Future<bool> removeAssetFromAlbum(
String albumId,
List<String> assetIds,
Album album,
Iterable<Asset> assets,
) async {
try {
await _apiService.albumApi.removeAssetFromAlbum(
albumId,
RemoveAssetsDto(assetIds: assetIds),
album.remoteId!,
RemoveAssetsDto(
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
),
);
return true;
@@ -156,17 +162,17 @@ class AlbumService {
}
Future<bool> changeTitleAlbum(
String albumId,
String ownerId,
Album album,
String newAlbumTitle,
) async {
try {
await _apiService.albumApi.updateAlbumInfo(
albumId,
album.remoteId!,
UpdateAlbumDto(
albumName: newAlbumTitle,
),
);
album.name = newAlbumTitle;
return true;
} catch (e) {

View File

@@ -1,32 +1,30 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';
class BaseAlbumCacheService extends JsonCache<List<AlbumResponseDto>> {
class BaseAlbumCacheService extends JsonCache<List<Album>> {
BaseAlbumCacheService(super.cacheFileName);
@override
void put(List<AlbumResponseDto> data) {
void put(List<Album> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
@override
Future<List<AlbumResponseDto>> get() async {
Future<List<Album>?> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = mapList
.map((e) => AlbumResponseDto.fromJson(e))
.whereNotNull()
.toList();
final responseData =
mapList.map((e) => Album.fromJson(e)).whereNotNull().toList();
return responseData;
} catch (e) {
await invalidate();
debugPrint(e.toString());
return [];
return null;
}
}
}
@@ -40,10 +38,9 @@ class SharedAlbumCacheService extends BaseAlbumCacheService {
}
final albumCacheServiceProvider = Provider(
(ref) => AlbumCacheService(),
(ref) => AlbumCacheService(),
);
final sharedAlbumCacheServiceProvider = Provider(
(ref) => SharedAlbumCacheService(),
(ref) => SharedAlbumCacheService(),
);

View File

@@ -8,10 +8,10 @@ import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:openapi/api.dart';
class AddToAlbumBottomSheet extends HookConsumerWidget {
/// The asset to add to an album
@@ -39,22 +39,22 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
[],
);
void addToAlbum(AlbumResponseDto album) async {
void addToAlbum(Album album) async {
final result = await albumService.addAdditionalAssetToAlbum(
assets,
album.id,
album,
);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: 'Already in ${album.albumName}',
msg: 'Already in ${album.name}',
);
} else {
ImmichToast.show(
context: context,
msg: 'Added to ${album.albumName}',
msg: 'Added to ${album.name}',
);
}
}

View File

@@ -1,14 +1,13 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AddToAlbumSliverList extends HookConsumerWidget {
/// The asset to add to an album
final List<AlbumResponseDto> albums;
final List<AlbumResponseDto> sharedAlbums;
final void Function(AlbumResponseDto) onAddToAlbum;
final List<Album> albums;
final List<Album> sharedAlbums;
final void Function(Album) onAddToAlbum;
const AddToAlbumSliverList({
Key? key,
@@ -21,36 +20,36 @@ class AddToAlbumSliverList extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: albums.length + (sharedAlbums.isEmpty ? 0 : 1),
(context, index) {
// Build shared expander
if (index == 0 && sharedAlbums.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ExpansionTile(
title: const Text('Shared'),
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
leading: const Icon(Icons.group),
children: sharedAlbums.map((album) =>
AlbumThumbnailListTile(
childCount: albums.length + (sharedAlbums.isEmpty ? 0 : 1),
(context, index) {
// Build shared expander
if (index == 0 && sharedAlbums.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ExpansionTile(
title: const Text('Shared'),
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
leading: const Icon(Icons.group),
children: sharedAlbums
.map(
(album) => AlbumThumbnailListTile(
album: album,
onTap: () => onAddToAlbum(album),
),
).toList(),
),
);
}
// Build albums list
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
final album = albums[offset];
return AlbumThumbnailListTile(
album: album,
onTap: () => onAddToAlbum(album),
)
.toList(),
),
);
}
),
// Build albums list
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
final album = albums[offset];
return AlbumThumbnailListTile(
album: album,
onTap: () => onAddToAlbum(album),
);
}),
);
}
}

View File

@@ -1,107 +1,114 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class AlbumThumbnailCard extends StatelessWidget {
final Function()? onTap;
const AlbumThumbnailCard({
Key? key,
required this.album,
this.onTap,
}) : super(key: key);
final AlbumResponseDto album;
final Album album;
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var cardSize = MediaQuery.of(context).size.width / 2 - 18;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
return LayoutBuilder(
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
buildEmptyThumbnail() {
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
child: SizedBox(
buildEmptyThumbnail() {
return Container(
height: cardSize,
width: cardSize,
child: const Center(
child: Icon(Icons.no_photography),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
),
);
}
buildAlbumThumbnail() {
return CachedNetworkImage(
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
);
}
return GestureDetector(
onTap: () {
AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id));
},
child: Padding(
padding: const EdgeInsets.only(bottom: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.albumThumbnailAssetId == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
child: Center(
child: Icon(
Icons.no_photography,
size: cardSize * .15,
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SizedBox(
width: cardSize,
child: Text(
album.albumName,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
);
}
buildAlbumThumbnail() {
return CachedNetworkImage(
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
);
}
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(bottom: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.albumThumbnailAssetId == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SizedBox(
width: cardSize,
child: Text(
album.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
album.assetCount == 1
? 'album_thumbnail_card_item'
: 'album_thumbnail_card_items',
style: const TextStyle(
fontSize: 12,
),
).tr(args: ['${album.assetCount}']),
if (album.shared)
const Text(
'album_thumbnail_card_shared',
style: TextStyle(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
album.assetCount == 1
? 'album_thumbnail_card_item'
: 'album_thumbnail_card_items',
style: const TextStyle(
fontSize: 12,
),
).tr()
],
)
],
).tr(args: ['${album.assetCount}']),
if (album.shared)
const Text(
'album_thumbnail_card_shared',
style: TextStyle(
fontSize: 12,
),
).tr()
],
)
],
),
),
),
);
},
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -15,7 +16,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
this.onTap,
}) : super(key: key);
final AlbumResponseDto album;
final Album album;
final void Function()? onTap;
@override
@@ -80,7 +81,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.albumName,
album.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),

View File

@@ -9,21 +9,19 @@ import 'package:immich_mobile/modules/album/providers/asset_selection.provider.d
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:openapi/api.dart';
class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
const AlbumViewerAppbar({
Key? key,
required this.albumInfo,
required this.album,
required this.userId,
required this.albumId,
}) : super(key: key);
final AlbumResponseDto albumInfo;
final Album album;
final String userId;
final String albumId;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -34,19 +32,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
void onDeleteAlbumPressed(String albumId) async {
void onDeleteAlbumPressed() async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess =
await ref.watch(albumServiceProvider).deleteAlbum(albumId);
bool isSuccess = await ref.watch(albumServiceProvider).deleteAlbum(album);
if (isSuccess) {
if (albumInfo.shared) {
ref.watch(sharedAlbumProvider.notifier).deleteAlbum(albumId);
if (album.shared) {
ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
ref.watch(albumProvider.notifier).deleteAlbum(albumId);
ref.watch(albumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
}
@@ -62,11 +59,11 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
ImmichLoadingOverlayController.appLoader.hide();
}
void onLeaveAlbumPressed(String albumId) async {
void onLeaveAlbumPressed() async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess =
await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(albumId);
await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(album);
if (isSuccess) {
AutoRouter.of(context)
@@ -84,20 +81,20 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
ImmichLoadingOverlayController.appLoader.hide();
}
void onRemoveFromAlbumPressed(String albumId) async {
void onRemoveFromAlbumPressed() async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess =
await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
albumId,
selectedAssetsInAlbum.map((a) => a.id).toList(),
album,
selectedAssetsInAlbum,
);
if (isSuccess) {
Navigator.pop(context);
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(sharedAlbumDetailProvider(albumId));
ref.invalidate(sharedAlbumDetailProvider(album.id));
} else {
Navigator.pop(context);
ImmichToast.show(
@@ -113,27 +110,27 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
buildBottomSheetActionButton() {
if (isMultiSelectionEnable) {
if (albumInfo.ownerId == userId) {
if (album.ownerId == userId) {
return ListTile(
leading: const Icon(Icons.delete_sweep_rounded),
title: const Text(
'album_viewer_appbar_share_remove',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onRemoveFromAlbumPressed(albumId),
onTap: () => onRemoveFromAlbumPressed(),
);
} else {
return const SizedBox();
}
} else {
if (albumInfo.ownerId == userId) {
if (album.ownerId == userId) {
return ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text(
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onDeleteAlbumPressed(albumId),
onTap: () => onDeleteAlbumPressed(),
);
} else {
return ListTile(
@@ -142,7 +139,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onLeaveAlbumPressed(albumId),
onTap: () => onLeaveAlbumPressed(),
);
}
}
@@ -180,7 +177,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
onPressed: () async {
bool isSuccess = await ref
.watch(albumViewerProvider.notifier)
.changeAlbumTitle(albumId, userId, newAlbumTitle);
.changeAlbumTitle(album, newAlbumTitle);
if (!isSuccess) {
ImmichToast.show(

View File

@@ -3,21 +3,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AlbumViewerEditableTitle extends HookConsumerWidget {
final AlbumResponseDto albumInfo;
final Album album;
final FocusNode titleFocusNode;
const AlbumViewerEditableTitle({
Key? key,
required this.albumInfo,
required this.album,
required this.titleFocusNode,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final titleTextEditController =
useTextEditingController(text: albumInfo.albumName);
final titleTextEditController = useTextEditingController(text: album.name);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
void onFocusModeChange() {
@@ -50,9 +49,7 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
onTap: () {
FocusScope.of(context).requestFocus(titleFocusNode);
ref
.watch(albumViewerProvider.notifier)
.setEditTitleText(albumInfo.albumName);
ref.watch(albumViewerProvider.notifier).setEditTitleText(album.name);
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
if (titleTextEditController.text == 'Untitled') {

View File

@@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -26,6 +27,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final isMultiSelectionEnable =
ref.watch(assetSelectionProvider).isMultiselectEnable;
final isFavorite = ref.watch(favoriteProvider).contains(asset.id);
viewAsset() {
AutoRouter.of(context).push(
@@ -85,9 +87,23 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
asset.isRemote
? (deviceId == asset.deviceId
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,
color: Colors.white,
size: 18,
),
);
}
buildAssetFavoriteIcon() {
return const Positioned(
left: 10,
bottom: 5,
child: Icon(
Icons.star,
color: Colors.white,
size: 18,
),
@@ -141,6 +157,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
child: Stack(
children: [
buildThumbnailImage(),
if (isFavorite) buildAssetFavoriteIcon(),
if (showStorageIndicator) buildAssetStoreLocationIcon(),
if (!asset.isImage) buildVideoLabel(),
if (isMultiSelectionEnable) buildAssetSelectionIcon(),

View File

@@ -121,7 +121,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
child: Row(
children: [
Text(
asset.duration.substring(0, 7),
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,

View File

@@ -19,11 +19,10 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:openapi/api.dart';
class AlbumViewerPage extends HookConsumerWidget {
final String albumId;
@@ -34,16 +33,16 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
ScrollController scrollController = useScrollController();
var albumInfo = ref.watch(sharedAlbumDetailProvider(albumId));
final album = ref.watch(sharedAlbumDetailProvider(albumId));
final userId = ref.watch(authenticationProvider).userId;
/// Find out if the assets in album exist on the device
/// If they exist, add to selected asset state to show they are already selected.
void onAddPhotosPressed(AlbumResponseDto albumInfo) async {
void onAddPhotosPressed(Album albumInfo) async {
if (albumInfo.assets.isNotEmpty == true) {
ref.watch(assetSelectionProvider.notifier).addNewAssets(
albumInfo.assets.map((e) => Asset.remote(e)).toList(),
albumInfo.assets,
);
}
@@ -60,7 +59,7 @@ class AlbumViewerPage extends HookConsumerWidget {
var addAssetsResult =
await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
returnPayload.selectedAdditionalAsset,
albumId,
albumInfo,
);
if (addAssetsResult != null &&
@@ -78,10 +77,10 @@ class AlbumViewerPage extends HookConsumerWidget {
}
}
void onAddUsersPressed(AlbumResponseDto albumInfo) async {
void onAddUsersPressed(Album album) async {
List<String>? sharedUserIds =
await AutoRouter.of(context).push<List<String>?>(
SelectAdditionalUserForSharingRoute(albumInfo: albumInfo),
SelectAdditionalUserForSharingRoute(album: album),
);
if (sharedUserIds != null) {
@@ -89,7 +88,7 @@ class AlbumViewerPage extends HookConsumerWidget {
var isSuccess = await ref
.watch(albumServiceProvider)
.addAdditionalUserToAlbum(sharedUserIds, albumId);
.addAdditionalUserToAlbum(sharedUserIds, album);
if (isSuccess) {
ref.invalidate(sharedAlbumDetailProvider(albumId));
@@ -99,18 +98,18 @@ class AlbumViewerPage extends HookConsumerWidget {
}
}
Widget buildTitle(AlbumResponseDto albumInfo) {
Widget buildTitle(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
child: userId == albumInfo.ownerId
child: userId == album.ownerId
? AlbumViewerEditableTitle(
albumInfo: albumInfo,
album: album,
titleFocusNode: titleFocusNode,
)
: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
albumInfo.albumName,
album.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
@@ -120,30 +119,22 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildAlbumDateRange(AlbumResponseDto albumInfo) {
String startDate = "";
DateTime parsedStartDate =
DateTime.parse(albumInfo.assets.first.createdAt);
DateTime parsedEndDate = DateTime.parse(
albumInfo.assets.last.createdAt,
); //Need default.
if (parsedStartDate.year == parsedEndDate.year) {
startDate = DateFormat('LLL d').format(parsedStartDate);
} else {
startDate = DateFormat('LLL d, y').format(parsedStartDate);
}
String endDate = DateFormat('LLL d, y').format(parsedEndDate);
Widget buildAlbumDateRange(Album album) {
final DateTime startDate = album.assets.first.createdAt;
final DateTime endDate = album.assets.last.createdAt; //Need default.
final String startDateText =
DateFormat(startDate.year == endDate.year ? 'LLL d' : 'LLL d, y')
.format(startDate);
final String endDateText = DateFormat('LLL d, y').format(endDate);
return Padding(
padding: EdgeInsets.only(
left: 16.0,
top: 8.0,
bottom: albumInfo.shared ? 0.0 : 8.0,
bottom: album.shared ? 0.0 : 8.0,
),
child: Text(
"$startDate-$endDate",
"$startDateText-$endDateText",
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@@ -153,15 +144,14 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildHeader(AlbumResponseDto albumInfo) {
Widget buildHeader(Album album) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildTitle(albumInfo),
if (albumInfo.assets.isNotEmpty == true)
buildAlbumDateRange(albumInfo),
if (albumInfo.shared)
buildTitle(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
if (album.shared)
SizedBox(
height: 60,
child: ListView.builder(
@@ -185,7 +175,7 @@ class AlbumViewerPage extends HookConsumerWidget {
),
);
}),
itemCount: albumInfo.sharedUsers.length,
itemCount: album.sharedUsers.length,
),
)
],
@@ -193,12 +183,12 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildImageGrid(AlbumResponseDto albumInfo) {
Widget buildImageGrid(Album album) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
if (albumInfo.assets.isNotEmpty) {
if (album.assets.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
@@ -211,13 +201,12 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return AlbumViewerThumbnail(
asset: Asset.remote(albumInfo.assets[index]),
assetList:
albumInfo.assets.map((e) => Asset.remote(e)).toList(),
asset: album.assets[index],
assetList: album.assets,
showStorageIndicator: showStorageIndicator,
);
},
childCount: albumInfo.assetCount,
childCount: album.assetCount,
),
),
);
@@ -225,7 +214,7 @@ class AlbumViewerPage extends HookConsumerWidget {
return const SliverToBoxAdapter();
}
Widget buildControlButton(AlbumResponseDto albumInfo) {
Widget buildControlButton(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
child: SizedBox(
@@ -235,13 +224,13 @@ class AlbumViewerPage extends HookConsumerWidget {
children: [
AlbumActionOutlinedButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: () => onAddPhotosPressed(albumInfo),
onPressed: () => onAddPhotosPressed(album),
labelText: "share_add_photos".tr(),
),
if (userId == albumInfo.ownerId)
if (userId == album.ownerId)
AlbumActionOutlinedButton(
iconData: Icons.person_add_alt_rounded,
onPressed: () => onAddUsersPressed(albumInfo),
onPressed: () => onAddUsersPressed(album),
labelText: "album_viewer_page_share_add_users".tr(),
),
],
@@ -251,7 +240,10 @@ class AlbumViewerPage extends HookConsumerWidget {
}
Future<bool> onWillPop() async {
final isMultiselectEnable = ref.read(assetSelectionProvider).selectedAssetsInAlbumViewer.isNotEmpty;
final isMultiselectEnable = ref
.read(assetSelectionProvider)
.selectedAssetsInAlbumViewer
.isNotEmpty;
if (isMultiselectEnable) {
ref.watch(assetSelectionProvider.notifier).removeAll();
return false;
@@ -260,7 +252,7 @@ class AlbumViewerPage extends HookConsumerWidget {
return true;
}
Widget buildBody(AlbumResponseDto albumInfo) {
Widget buildBody(Album album) {
return WillPopScope(
onWillPop: onWillPop,
child: GestureDetector(
@@ -274,7 +266,7 @@ class AlbumViewerPage extends HookConsumerWidget {
child: CustomScrollView(
controller: scrollController,
slivers: [
buildHeader(albumInfo),
buildHeader(album),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
@@ -282,11 +274,11 @@ class AlbumViewerPage extends HookConsumerWidget {
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(albumInfo),
child: buildControlButton(album),
),
),
),
buildImageGrid(albumInfo)
buildImageGrid(album)
],
),
),
@@ -295,13 +287,12 @@ class AlbumViewerPage extends HookConsumerWidget {
}
return Scaffold(
appBar: albumInfo.when(
data: (AlbumResponseDto? data) {
appBar: album.when(
data: (Album? data) {
if (data != null) {
return AlbumViewerAppbar(
albumInfo: data,
album: data,
userId: userId,
albumId: albumId,
);
}
return null;
@@ -309,7 +300,7 @@ class AlbumViewerPage extends HookConsumerWidget {
error: (e, _) => null,
loading: () => null,
),
body: albumInfo.when(
body: album.when(
data: (albumInfo) => albumInfo != null
? buildBody(albumInfo)
: const Center(

View File

@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -6,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
class LibraryPage extends HookConsumerWidget {
const LibraryPage({Key? key}) : super(key: key);
@@ -22,14 +24,11 @@ class LibraryPage extends HookConsumerWidget {
[],
);
Widget buildAppBar() {
return const SliverAppBar(
AppBar buildAppBar() {
return AppBar(
centerTitle: true,
floating: true,
pinned: false,
snap: false,
automaticallyImplyLeading: false,
title: Text(
title: const Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
@@ -40,74 +39,236 @@ class LibraryPage extends HookConsumerWidget {
);
}
Widget buildCreateAlbumButton() {
return GestureDetector(
onTap: () {
AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false));
},
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: MediaQuery.of(context).size.width / 2 - 18,
height: MediaQuery.of(context).size.width / 2 - 18,
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey,
),
borderRadius: BorderRadius.circular(8),
final selectedAlbumSortOrder = useState(0);
List<Album> sortedAlbums() {
if (selectedAlbumSortOrder.value == 0) {
return albums.sortedBy((album) => album.createdAt).reversed.toList();
}
return albums.sortedBy((album) => album.name);
}
Widget buildSortButton() {
final options = [
"library_page_sort_created".tr(),
"library_page_sort_title".tr()
];
return PopupMenuButton(
position: PopupMenuPosition.over,
itemBuilder: (BuildContext context) {
return options.mapIndexed<PopupMenuEntry<int>>((index, option) {
final selected = selectedAlbumSortOrder.value == index;
return PopupMenuItem(
value: index,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Icon(
Icons.check,
color: selected
? Theme.of(context).primaryColor
: Colors.transparent,
),
),
Text(
option,
style: TextStyle(
color: selected ? Theme.of(context).primaryColor : null,
fontSize: 12.0,
),
)
],
),
child: Center(
child: Icon(
Icons.add_rounded,
size: 28,
color: Theme.of(context).primaryColor,
),
);
}).toList();
},
onSelected: (int value) {
selectedAlbumSortOrder.value = value;
},
child: Row(
children: [
Icon(
Icons.swap_vert_rounded,
size: 18,
color: Theme.of(context).primaryColor,
),
Text(
options[selectedAlbumSortOrder.value],
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
fontSize: 12.0,
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: const Text(
'library_page_new_album',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
)
],
),
);
}
return Scaffold(
body: CustomScrollView(
slivers: [
buildAppBar(),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: const Text(
'library_page_albums',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
Widget buildCreateAlbumButton() {
return GestureDetector(
onTap: () {
AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false));
},
child: Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey,
),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Icon(
Icons.add_rounded,
size: 28,
color: Theme.of(context).primaryColor,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 16,
),
child: const Text(
'library_page_new_album',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
),
],
),
),
);
}
Widget buildLibraryNavButton(
String label,
IconData icon,
Function() onClick,
) {
return Expanded(
child: OutlinedButton.icon(
onPressed: onClick,
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12.0,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
),
),
),
SliverPadding(
padding: const EdgeInsets.only(left: 12.0, right: 12, bottom: 50),
sliver: SliverToBoxAdapter(
child: Wrap(
spacing: 12,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.all(12),
side: BorderSide(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[600]!
: Colors.grey[300]!,
),
alignment: Alignment.centerLeft,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6.0),
),
),
icon: Icon(icon, color: Theme.of(context).primaryColor),
),
);
}
final sorted = sortedAlbums();
return Scaffold(
appBar: buildAppBar(),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
top: 24.0,
bottom: 12.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildCreateAlbumButton(),
for (var album in albums)
AlbumThumbnailCard(
album: album,
),
buildLibraryNavButton(
"library_page_favorites".tr(), Icons.star_border, () {
AutoRouter.of(context).navigate(const FavoritesRoute());
}),
const SizedBox(width: 12.0),
buildLibraryNavButton(
"library_page_sharing".tr(), Icons.group_outlined, () {
AutoRouter.of(context).navigate(const SharingRoute());
}),
],
),
),
)
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'library_page_albums',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
buildSortButton(),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: sorted.length + 1,
(context, index) {
if (index == 0) {
return buildCreateAlbumButton();
}
return AlbumThumbnailCard(
album: sorted[index - 1],
onTap: () => AutoRouter.of(context).push(
AlbumViewerRoute(
albumId: sorted[index - 1].id,
),
),
);
},
),
),
),
],
),
);

View File

@@ -4,27 +4,28 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:openapi/api.dart';
class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
final AlbumResponseDto albumInfo;
final Album album;
const SelectAdditionalUserForSharingPage({Key? key, required this.albumInfo})
const SelectAdditionalUserForSharingPage({Key? key, required this.album})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<UserResponseDto>> suggestedShareUsers =
final AsyncValue<List<User>> suggestedShareUsers =
ref.watch(suggestedSharedUsersProvider);
final sharedUsersList = useState<Set<UserResponseDto>>({});
final sharedUsersList = useState<Set<User>>({});
addNewUsersHandler() {
AutoRouter.of(context)
.pop(sharedUsersList.value.map((e) => e.id).toList());
}
buildTileIcon(UserResponseDto user) {
buildTileIcon(User user) {
if (sharedUsersList.value.contains(user)) {
return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
@@ -42,7 +43,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
}
}
buildUserList(List<UserResponseDto> users) {
buildUserList(List<User> users) {
List<Widget> usersChip = [];
for (var user in sharedUsersList.value) {
@@ -140,9 +141,9 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
),
body: suggestedShareUsers.when(
data: (users) {
for (var sharedUsers in albumInfo.sharedUsers) {
for (var sharedUsers in album.sharedUsers) {
users.removeWhere(
(u) => u.id == sharedUsers.id || u.id == albumInfo.ownerId,
(u) => u.id == sharedUsers.id || u.id == album.ownerId,
);
}

View File

@@ -8,16 +8,16 @@ import 'package:immich_mobile/modules/album/providers/asset_selection.provider.d
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:openapi/api.dart';
class SelectUserForSharingPage extends HookConsumerWidget {
const SelectUserForSharingPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedUsersList = useState<Set<UserResponseDto>>({});
AsyncValue<List<UserResponseDto>> suggestedShareUsers =
final sharedUsersList = useState<Set<User>>({});
AsyncValue<List<User>> suggestedShareUsers =
ref.watch(suggestedSharedUsersProvider);
createSharedAlbum() async {
@@ -25,7 +25,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum(
ref.watch(albumTitleProvider),
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
sharedUsersList.value.map((userInfo) => userInfo.id).toList(),
sharedUsersList.value,
);
if (newAlbum != null) {
@@ -44,7 +44,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
);
}
buildTileIcon(UserResponseDto user) {
buildTileIcon(User user) {
if (sharedUsersList.value.contains(user)) {
return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
@@ -62,7 +62,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
}
}
buildUserList(List<UserResponseDto> users) {
buildUserList(List<User> users) {
List<Widget> usersChip = [];
for (var user in sharedUsersList.value) {

View File

@@ -9,8 +9,8 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class SharingPage extends HookConsumerWidget {
const SharingPage({Key? key}) : super(key: key);
@@ -18,7 +18,7 @@ class SharingPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect(
() {
@@ -52,7 +52,7 @@ class SharingPage extends HookConsumerWidget {
),
),
title: Text(
sharedAlbums[index].albumName,
sharedAlbums[index].name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(

View File

@@ -7,7 +7,6 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:openapi/api.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService;
@@ -20,7 +19,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
),
);
void downloadAsset(AssetResponseDto asset, BuildContext context) async {
void downloadAsset(Asset asset, BuildContext context) async {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);

View File

@@ -2,10 +2,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart';
@@ -18,14 +17,12 @@ class ImageViewerService {
ImageViewerService(this._apiService);
Future<bool> downloadAssetToDevice(AssetResponseDto asset) async {
Future<bool> downloadAssetToDevice(Asset asset) async {
try {
String fileName = p.basename(asset.originalPath);
// Download LivePhotos image and motion part
if (asset.type == AssetTypeEnum.IMAGE && asset.livePhotoVideoId != null) {
if (asset.isImage && asset.livePhotoVideoId != null) {
var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.id,
asset.remoteId!,
);
var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
@@ -43,28 +40,28 @@ class ImageViewerService {
entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: imageFile,
videoFile: videoFile,
title: p.basename(asset.originalPath),
title: asset.fileName,
);
return entity != null;
} else {
var res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.id,
);
var res = await _apiService.assetApi
.downloadFileWithHttpInfo(asset.remoteId!);
final AssetEntity? entity;
if (asset.type == AssetTypeEnum.IMAGE) {
if (asset.isImage) {
entity = await PhotoManager.editor.saveImage(
res.bodyBytes,
title: p.basename(asset.originalPath),
title: asset.fileName,
);
} else {
final tempDir = await getTemporaryDirectory();
File tempFile = await File('${tempDir.path}/$fileName').create();
File tempFile =
await File('${tempDir.path}/${asset.fileName}').create();
tempFile.writeAsBytesSync(res.bodyBytes);
entity =
await PhotoManager.editor.saveVideo(tempFile, title: fileName);
entity = await PhotoManager.editor
.saveVideo(tempFile, title: asset.fileName);
}
return entity != null;
}

View File

@@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
@@ -68,7 +67,7 @@ class ExifBottomSheet extends HookConsumerWidget {
final textColor = Theme.of(context).primaryColor;
ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
ExifInfo? exifInfo = assetDetail.exifInfo;
buildLocationText() {
return Text(
@@ -81,6 +80,17 @@ class ExifBottomSheet extends HookConsumerWidget {
);
}
buildSizeText(Asset a) {
String resolution = a.width != null && a.height != null
? "${a.height} x ${a.width} "
: "";
String fileSize = a.exifInfo?.fileSize != null
? formatBytes(a.exifInfo!.fileSize!)
: "";
String text = resolution + fileSize;
return text.isEmpty ? null : Text(text);
}
return SingleChildScrollView(
child: Card(
shape: const RoundedRectangleBorder(
@@ -101,19 +111,18 @@ class ExifBottomSheet extends HookConsumerWidget {
child: CustomDraggingHandle(),
),
const SizedBox(height: 12),
if (exifInfo?.dateTimeOriginal != null)
Text(
DateFormat('date_format'.tr()).format(
exifInfo!.dateTimeOriginal!.toLocal(),
),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
Text(
DateFormat('date_format'.tr()).format(
assetDetail.createdAt.toLocal(),
),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
// Location
if (assetDetail.latitude != null)
if (assetDetail.latitude != null && assetDetail.longitude != null)
Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
@@ -126,74 +135,67 @@ class ExifBottomSheet extends HookConsumerWidget {
"exif_bottom_sheet_location",
style: TextStyle(fontSize: 11, color: textColor),
).tr(),
if (assetDetail.latitude != null &&
assetDetail.longitude != null)
buildMap(),
buildMap(),
if (exifInfo != null &&
exifInfo.city != null &&
exifInfo.state != null)
buildLocationText(),
Text(
"${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}",
"${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12),
)
],
),
),
// Detail
if (exifInfo != null)
Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"exif_bottom_sheet_details",
style: TextStyle(fontSize: 11, color: textColor),
).tr(),
Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"exif_bottom_sheet_details",
style: TextStyle(fontSize: 11, color: textColor),
).tr(),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: const Icon(Icons.image),
title: Text(
assetDetail.fileName,
style: TextStyle(
fontWeight: FontWeight.bold,
color: textColor,
),
),
subtitle: buildSizeText(assetDetail),
),
if (exifInfo?.make != null)
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: const Icon(Icons.image),
leading: const Icon(Icons.camera),
title: Text(
"${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}",
"${exifInfo!.make} ${exifInfo.model}",
style: TextStyle(
fontWeight: FontWeight.bold,
color: textColor,
fontWeight: FontWeight.bold,
),
),
subtitle: exifInfo.exifImageHeight != null
? Text(
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte ?? 0)} ",
)
: null,
subtitle: Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
),
),
if (exifInfo.make != null)
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: const Icon(Icons.camera),
title: Text(
"${exifInfo.make} ${exifInfo.model}",
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
),
),
],
),
],
),
),
const SizedBox(
height: 50,
),

View File

@@ -14,6 +14,8 @@ class TopControlAppBar extends HookConsumerWidget {
required this.onAddToAlbumPressed,
required this.onToggleMotionVideo,
required this.isPlayingMotionVideo,
required this.onFavorite,
required this.isFavorite,
}) : super(key: key);
final Asset asset;
@@ -22,13 +24,29 @@ class TopControlAppBar extends HookConsumerWidget {
final VoidCallback onToggleMotionVideo;
final VoidCallback onDeletePressed;
final VoidCallback onAddToAlbumPressed;
final VoidCallback onFavorite;
final Function onSharePressed;
final bool isPlayingMotionVideo;
final bool isFavorite;
@override
Widget build(BuildContext context, WidgetRef ref) {
double iconSize = 18.0;
Widget buildFavoriteButton() {
return IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onFavorite();
},
icon: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: Colors.grey[200],
),
);
}
return AppBar(
foregroundColor: Colors.grey[100],
backgroundColor: Colors.transparent,
@@ -43,7 +61,8 @@ class TopControlAppBar extends HookConsumerWidget {
),
),
actions: [
if (asset.remote?.livePhotoVideoId != null)
if (asset.isRemote) buildFavoriteButton(),
if (asset.livePhotoVideoId != null)
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
@@ -104,18 +123,17 @@ class TopControlAppBar extends HookConsumerWidget {
color: Colors.grey[200],
),
),
if (asset.isRemote)
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onMoreInfoPressed();
},
icon: Icon(
Icons.more_horiz_rounded,
color: Colors.grey[200],
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onMoreInfoPressed();
},
icon: Icon(
Icons.more_horiz_rounded,
color: Colors.grey[200],
),
),
],
);
}

View File

@@ -13,7 +13,8 @@ import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_s
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -51,6 +52,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final showAppBar = useState<bool>(true);
final indexOfAsset = useState(assetList.indexOf(asset));
final isPlayingMotionVideo = useState(false);
final isPlayingVideo = useState(false);
late Offset localPosition;
final authToken = 'Bearer ${box.get(accessTokenKey)}';
@@ -69,7 +71,11 @@ class GalleryViewerPage extends HookConsumerWidget {
[],
);
void getAssetExif() async {
void toggleFavorite(Asset asset) {
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
}
getAssetExif() async {
if (assetList[indexOfAsset.value].isRemote) {
assetDetail = await ref
.watch(assetServiceProvider)
@@ -80,31 +86,34 @@ class GalleryViewerPage extends HookConsumerWidget {
}
}
/// Thumbnail image of a remote asset. Required asset.remote != null
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) {
/// Thumbnail image of a remote asset. Required asset.isRemote
ImageProvider remoteThumbnailImageProvider(
Asset asset,
api.ThumbnailFormat type,
) {
return CachedNetworkImageProvider(
getThumbnailUrl(
asset.remote!,
asset,
type: type,
),
cacheKey: getThumbnailCacheKey(
asset.remote!,
asset,
type: type,
),
headers: {"Authorization": authToken},
);
}
/// Original (large) image of a remote asset. Required asset.remote != null
/// Original (large) image of a remote asset. Required asset.isRemote
ImageProvider originalImageProvider(Asset asset) {
return CachedNetworkImageProvider(
getImageUrl(asset.remote!),
cacheKey: getImageCacheKey(asset.remote!),
getImageUrl(asset),
cacheKey: getImageCacheKey(asset),
headers: {"Authorization": authToken},
);
}
/// Thumbnail image of a local asset. Required asset.local != null
/// Thumbnail image of a local asset. Required asset.isLocal
ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider(
asset.local!,
@@ -114,10 +123,9 @@ class GalleryViewerPage extends HookConsumerWidget {
MediaQuery.of(context).size.height.floor(),
),
);
}
/// Original (large) image of a local asset. Required asset.local != null
/// Original (large) image of a local asset. Required asset.isLocal
ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(asset.local!);
}
@@ -132,7 +140,7 @@ class GalleryViewerPage extends HookConsumerWidget {
// Probably load WEBP either way
precacheImage(
remoteThumbnailImageProvider(
asset,
asset,
api.ThumbnailFormat.WEBP,
),
context,
@@ -154,26 +162,23 @@ class GalleryViewerPage extends HookConsumerWidget {
context,
);
}
}
}
}
void showInfo() {
if (assetList[indexOfAsset.value].isRemote) {
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
void handleDelete(Asset deleteAsset) {
@@ -229,22 +234,32 @@ class GalleryViewerPage extends HookConsumerWidget {
}
buildAppBar() {
final show = (showAppBar.value || // onTap has the final say
(showAppBar.value && !isZoomed.value)) &&
!isPlayingVideo.value;
return AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: (showAppBar.value || !isZoomed.value) ? 1.0 : 0.0,
opacity: show ? 1.0 : 0.0,
child: Container(
color: Colors.black.withOpacity(0.4),
child: TopControlAppBar(
isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: assetList[indexOfAsset.value],
isFavorite: ref.watch(favoriteProvider).contains(
assetList[indexOfAsset.value].id,
),
onMoreInfoPressed: () {
showInfo();
},
onFavorite: () {
toggleFavorite(assetList[indexOfAsset.value]);
},
onDownloadPressed: assetList[indexOfAsset.value].isLocal
? null
: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(
assetList[indexOfAsset.value].remote!,
assetList[indexOfAsset.value],
context,
);
},
@@ -256,8 +271,10 @@ class GalleryViewerPage extends HookConsumerWidget {
onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}),
onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
onDeletePressed: () =>
handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () =>
addToAlbum(assetList[indexOfAsset.value]),
),
),
);
@@ -268,117 +285,138 @@ class GalleryViewerPage extends HookConsumerWidget {
body: Stack(
children: [
PhotoViewGallery.builder(
scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial;
showAppBar.value = !isZoomed.value;
},
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value ? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP),
httpHeaders: { 'Authorization': authToken },
progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
);
return CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG),
httpHeaders: { 'Authorization': authToken },
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
);
} else {
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
} : null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value,
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value,
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
maxScale: 1.0,
minScale: 1.0,
child: SafeArea(
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
),
scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial;
showAppBar.value = !isZoomed.value;
},
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
);
}
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: buildAppBar(),
),
],
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value
? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(
asset,
type: api.ThumbnailFormat.WEBP,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.WEBP,
),
httpHeaders: {'Authorization': authToken},
progressIndicatorBuilder: (_, __, ___) => const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
);
return CachedNetworkImage(
imageUrl: getThumbnailUrl(
asset,
type: api.ThumbnailFormat.JPEG,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.JPEG,
),
httpHeaders: {'Authorization': authToken},
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
);
} else {
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
}
: null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
imageProvider: provider,
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
maxScale: 1.0,
minScale: 1.0,
child: SafeArea(
child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false,
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
),
),
);
}
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: buildAppBar(),
),
],
),
);
}
}

View File

@@ -17,12 +17,16 @@ class VideoViewerPage extends HookConsumerWidget {
final Asset asset;
final bool isMotionVideo;
final VoidCallback onVideoEnded;
final VoidCallback? onPlaying;
final VoidCallback? onPaused;
const VideoViewerPage({
Key? key,
required this.asset,
required this.isMotionVideo,
required this.onVideoEnded,
this.onPlaying,
this.onPaused,
}) : super(key: key);
@override
@@ -53,8 +57,8 @@ class VideoViewerPage extends HookConsumerWidget {
final box = Hive.box(userInfoBox);
final String jwtToken = box.get(accessTokenKey);
final String videoUrl = isMotionVideo
? '${box.get(serverEndpointKey)}/asset/file/${asset.remote?.livePhotoVideoId!}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.id}';
? '${box.get(serverEndpointKey)}/asset/file/${asset.livePhotoVideoId}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}';
return Stack(
children: [
@@ -63,6 +67,8 @@ class VideoViewerPage extends HookConsumerWidget {
jwtToken: jwtToken,
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded,
onPaused: onPaused,
onPlaying: onPlaying,
),
if (downloadAssetStatus == DownloadAssetStatus.loading)
const Center(
@@ -89,6 +95,9 @@ class VideoThumbnailPlayer extends StatefulWidget {
final bool isMotionVideo;
final VoidCallback onVideoEnded;
final Function()? onPlaying;
final Function()? onPaused;
const VideoThumbnailPlayer({
Key? key,
this.url,
@@ -96,6 +105,8 @@ class VideoThumbnailPlayer extends StatefulWidget {
this.file,
required this.onVideoEnded,
required this.isMotionVideo,
this.onPlaying,
this.onPaused,
}) : super(key: key);
@override
@@ -112,6 +123,11 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
initializePlayer();
videoPlayerController.addListener(() {
if (videoPlayerController.value.isPlaying) {
widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) {
widget.onPaused?.call();
}
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
widget.onVideoEnded();

View File

@@ -75,6 +75,9 @@ class BackupService {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =

View File

@@ -0,0 +1,64 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
FavoriteSelectionNotifier(this.ref) : super({}) {
state = ref.watch(assetProvider).allAssets
.where((asset) => asset.isFavorite)
.map((asset) => asset.id)
.toSet();
}
final Ref ref;
void _setFavoriteForAssetId(String id, bool favorite) {
if (!favorite) {
state = state.difference({id});
} else {
state = state.union({id});
}
}
bool _isFavorite(String id) {
return state.contains(id);
}
Future<void> toggleFavorite(Asset asset) async {
if (!asset.isRemote) return; // TODO support local favorite assets
_setFavoriteForAssetId(asset.id, !_isFavorite(asset.id));
await ref.watch(assetProvider.notifier).toggleFavorite(
asset,
state.contains(asset.id),
);
}
Future<void> addToFavorites(Iterable<Asset> assets) {
state = state.union(assets.map((a) => a.id).toSet());
final futures = assets.map((a) =>
ref.watch(assetProvider.notifier).toggleFavorite(
a,
true,
),
);
return Future.wait(futures);
}
}
final favoriteProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) {
return FavoriteSelectionNotifier(ref);
});
final favoriteAssetProvider = StateProvider((ref) {
final favorites = ref.watch(favoriteProvider);
return ref
.watch(assetProvider)
.allAssets
.where((element) => favorites.contains(element.id))
.toList();
});

View File

@@ -0,0 +1,36 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class FavoriteImage extends HookConsumerWidget {
final Asset asset;
final List<Asset> assets;
const FavoriteImage(this.asset, this.assets, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
void viewAsset() {
AutoRouter.of(context).push(
GalleryViewerRoute(
asset: asset,
assetList: assets,
),
);
}
return GestureDetector(
onTap: viewAsset,
child: ImmichImage(
asset,
width: 300,
height: 300,
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/favorite/ui/favorite_image.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
AppBar buildAppBar() {
return AppBar(
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text(
'favorites_page_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildImageGrid() {
final appSettingService = ref.watch(appSettingsServiceProvider);
if (ref.watch(favoriteAssetProvider).isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate(
(
BuildContext context,
int index,
) {
return FavoriteImage(
ref.watch(favoriteAssetProvider)[index],
ref.watch(favoriteAssetProvider),
);
},
childCount: ref.watch(favoriteAssetProvider).length,
),
),
);
}
return const SliverToBoxAdapter();
}
return Scaffold(
appBar: buildAppBar(),
body: CustomScrollView(
slivers: [buildImageGrid()],
),
);
}
}

View File

@@ -1,76 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:openapi/api.dart';
class ImmichAssetGroupByDate {
final String date;
List<AssetResponseDto> assets;
ImmichAssetGroupByDate({
required this.date,
required this.assets,
});
ImmichAssetGroupByDate copyWith({
String? date,
List<AssetResponseDto>? assets,
}) {
return ImmichAssetGroupByDate(
date: date ?? this.date,
assets: assets ?? this.assets,
);
}
@override
String toString() => 'ImmichAssetGroupByDate(date: $date, assets: $assets)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichAssetGroupByDate &&
other.date == date &&
listEquals(other.assets, assets);
}
@override
int get hashCode => date.hashCode ^ assets.hashCode;
}
class GetAllAssetResponse {
final int count;
final List<ImmichAssetGroupByDate> data;
final String nextPageKey;
GetAllAssetResponse({
required this.count,
required this.data,
required this.nextPageKey,
});
GetAllAssetResponse copyWith({
int? count,
List<ImmichAssetGroupByDate>? data,
String? nextPageKey,
}) {
return GetAllAssetResponse(
count: count ?? this.count,
data: data ?? this.data,
nextPageKey: nextPageKey ?? this.nextPageKey,
);
}
@override
String toString() =>
'GetAllAssetResponse(count: $count, data: $data, nextPageKey: $nextPageKey)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetAllAssetResponse &&
other.count == count &&
listEquals(other.data, data) &&
other.nextPageKey == nextPageKey;
}
@override
int get hashCode => count.hashCode ^ data.hashCode ^ nextPageKey.hashCode;
}

View File

@@ -1,5 +1,6 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -9,14 +10,15 @@ final log = Logger('AssetGridDataStructure');
enum RenderAssetGridElementType {
assetRow,
dayTitle,
groupDividerTitle,
monthTitle;
}
class RenderAssetGridRow {
final List<Asset> assets;
final List<double> widthDistribution;
RenderAssetGridRow(this.assets);
RenderAssetGridRow(this.assets, this.widthDistribution);
}
class RenderAssetGridElement {
@@ -35,19 +37,36 @@ class RenderAssetGridElement {
});
}
enum GroupAssetsBy {
day,
month;
}
class AssetGridLayoutParameters {
final int perRow;
final bool dynamicLayout;
final GroupAssetsBy groupBy;
AssetGridLayoutParameters(
this.perRow,
this.dynamicLayout,
this.groupBy,
);
}
class _AssetGroupsToRenderListComputeParameters {
final String monthFormat;
final String dayFormat;
final String dayFormatYear;
final Map<String, List<Asset>> groups;
final int perRow;
final List<Asset> assets;
final AssetGridLayoutParameters layout;
_AssetGroupsToRenderListComputeParameters(
this.monthFormat,
this.dayFormat,
this.dayFormatYear,
this.groups,
this.perRow,
this.assets,
this.layout,
);
}
@@ -56,62 +75,75 @@ class RenderList {
RenderList(this.elements);
static Map<String, List<Asset>> _groupAssets(
List<Asset> assets,
GroupAssetsBy groupBy,
) {
assets.sortByCompare<DateTime>(
(e) => e.createdAt,
(a, b) => b.compareTo(a),
);
if (groupBy == GroupAssetsBy.day) {
return assets.groupListsBy(
(element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
);
} else if (groupBy == GroupAssetsBy.month) {
return assets.groupListsBy(
(element) => DateFormat('y-MM').format(element.createdAt.toLocal()),
);
}
return {};
}
static Future<RenderList> _processAssetGroupData(
_AssetGroupsToRenderListComputeParameters data,
) async {
final monthFormat = DateFormat(data.monthFormat);
final dayFormatSameYear = DateFormat(data.dayFormat);
final dayFormatOtherYear = DateFormat(data.dayFormatYear);
final groups = data.groups;
final perRow = data.perRow;
final allAssets = data.assets;
final perRow = data.layout.perRow;
final dynamicLayout = data.layout.dynamicLayout;
final groupBy = data.layout.groupBy;
List<RenderAssetGridElement> elements = [];
DateTime? lastDate;
final groups = _groupAssets(allAssets, groupBy);
groups.forEach((groupName, assets) {
try {
final date = DateTime.parse(groupName);
if (lastDate == null || lastDate!.month != date.month) {
// Month title
var monthTitleText = groupName;
var groupDate = DateTime.tryParse(groupName);
if (groupDate != null) {
monthTitleText = monthFormat.format(groupDate);
} else {
log.severe("Failed to format date for day title: $groupName");
}
final date = assets.first.createdAt.toLocal();
// Month title
if (groupBy == GroupAssetsBy.day &&
(lastDate == null || lastDate!.month != date.month)) {
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
title: monthTitleText,
title: monthFormat.format(date),
date: date,
),
);
}
// Add group title
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(groupName).year;
var formatDate =
currentYear == groupYear ? dayFormatSameYear : dayFormatOtherYear;
// Group divider title (day or month)
var formatDate = dayFormatOtherYear;
var dateText = groupName;
if (DateTime.now().year == date.year) {
formatDate = dayFormatSameYear;
}
var groupDate = DateTime.tryParse(groupName);
if (groupDate != null) {
dateText = formatDate.format(groupDate);
} else {
log.severe("Failed to format date for day title: $groupName");
if (groupBy == GroupAssetsBy.month) {
formatDate = monthFormat;
}
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.dayTitle,
title: dateText,
RenderAssetGridElementType.groupDividerTitle,
title: formatDate.format(date),
date: date,
relatedAssetList: assets,
),
@@ -121,12 +153,37 @@ class RenderList {
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, perRow);
final rowAssets = assets.sublist(cursor, cursor + rowElements);
// Default: All assets have the same width
var widthDistribution = List.filled(rowElements, 1.0);
if (dynamicLayout) {
final aspectRatios =
rowAssets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
final meanAspectRatio = aspectRatios.sum / rowElements;
// 1: mean width
// 0.5: width < mean - threshold
// 1.5: width > mean + threshold
final arConfiguration = aspectRatios.map((e) {
if (e - meanAspectRatio > 0.3) return 1.5;
if (e - meanAspectRatio < -0.3) return 0.5;
return 1.0;
});
// Normalize:
final sum = arConfiguration.sum;
widthDistribution =
arConfiguration.map((e) => (e * rowElements) / sum).toList();
}
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
date: date,
assetRow: RenderAssetGridRow(
assets.sublist(cursor, cursor + rowElements),
rowAssets,
widthDistribution,
),
);
@@ -143,9 +200,9 @@ class RenderList {
return RenderList(elements);
}
static Future<RenderList> fromAssetGroups(
Map<String, List<Asset>> assetGroups,
int assetsPerRow,
static Future<RenderList> fromAssets(
List<Asset> assets,
AssetGridLayoutParameters layout,
) async {
// Compute only allows for one parameter. Therefore we pass all parameters in a map
return compute(
@@ -154,8 +211,8 @@ class RenderList {
"monthly_title_text_date_format".tr(),
"daily_title_text_date".tr(),
"daily_title_text_date_year".tr(),
assetGroups,
assetsPerRow,
assets,
layout,
),
);
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
class GroupDividerTitle extends ConsumerWidget {
const GroupDividerTitle({
Key? key,
required this.text,
required this.multiselectEnabled,

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'daily_title_text.dart';
import 'group_divider_title.dart';
import 'disable_multi_select_button.dart';
import 'draggable_scrollbar_custom.dart';
@@ -24,7 +24,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
bool _scrolling = false;
final Set<String> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() {
return _selectedAssets
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
@@ -67,11 +66,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
}
double _getItemSize(BuildContext context) {
return MediaQuery.of(context).size.width / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
}
Widget _buildThumbnailOrPlaceholder(
Asset asset,
bool placeholder,
@@ -98,24 +92,29 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
RenderAssetGridRow row,
bool scrolling,
) {
double size = _getItemSize(context);
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((Asset asset) {
bool last = asset == row.assets.last;
return LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.mapIndexed((int index, Asset asset) {
bool last = asset.id == row.assets.last.id;
return Container(
key: Key("asset-${asset.id}"),
width: size,
height: size,
margin: EdgeInsets.only(
top: widget.margin,
right: last ? 0.0 : widget.margin,
),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
return Container(
key: Key("asset-${asset.id}"),
width: size * row.widthDistribution[index],
height: size,
margin: EdgeInsets.only(
top: widget.margin,
right: last ? 0.0 : widget.margin,
),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
}).toList(),
},
);
}
@@ -124,7 +123,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
String title,
List<Asset> assets,
) {
return DailyTitleText(
return GroupDividerTitle(
text: title,
multiselectEnabled: widget.selectionActive,
onSelect: () => _selectAssets(assets),
@@ -151,7 +150,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
Widget _itemBuilder(BuildContext c, int position) {
final item = widget.renderList.elements[position];
if (item.type == RenderAssetGridElementType.dayTitle) {
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
@@ -224,7 +223,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
}
}
Future<bool> onWillPop() async {
if (widget.selectionActive && _selectedAssets.isNotEmpty) {
_deselectAll();
@@ -234,8 +232,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
return true;
}
@override
Widget build(BuildContext context) {
return WillPopScope(

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -110,6 +111,16 @@ class ThumbnailImage extends HookConsumerWidget {
size: 18,
),
),
if (ref.watch(favoriteProvider).contains(asset.id))
const Positioned(
left: 10,
bottom: 5,
child: Icon(
Icons.star,
color: Colors.white,
size: 18,
),
),
if (!asset.isImage)
Positioned(
top: 5,

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