Compare commits

...

38 Commits

Author SHA1 Message Date
Alex
46f348825d reset memory on navigate to photos page 2025-08-04 18:03:49 -05:00
Alex
17e6ca962a merge main 2025-08-04 17:26:54 -05:00
Brandon Wees
750d21aeba fix(mobile): use storageIndicator setting for beta timeline (#20639)
* fix: use storageIndicator setting for beta timeline

* fix: reactively update the storage indicator icons when setting is changed

* Update drift_trash.page.dart

* override to bool for storageIndicator
2025-08-04 17:25:58 -05:00
Paweł Wojtaszko
990d9ba9a8 fix: adjust margin and gap for trailing elements in control app bar (#20645) 2025-08-04 17:24:19 -05:00
Alex
b05e931ed8 fix: formatting 2025-08-04 17:22:05 -05:00
Brandon Wees
4d0c9172e5 fix: not clearing local data when logging out while sync is running (#20646) 2025-08-04 17:14:26 -05:00
Brandon Wees
fab63e6b2a Update main_timeline.page.dart 2025-08-04 16:45:26 -05:00
Brandon Wees
094e3a2757 fix(mobile): cleanly handle logout when no host is set (#20521)
* fix: cleanly handle logging out when no host is set on API

* move conditional to auth_api repo
2025-08-05 03:11:58 +05:30
Zack Pollard
278668b8c5 fix: improvements to sync and upload when resuming app (#20524)
- App will now kick off hashing after local sync if the lifecycle is in resumed or active state
- We now wait for hashing to complete before we kick off the upload process
2025-08-05 03:11:44 +05:30
bwees
3ef9e36f38 fix(mobile): disable memory lane when memories are disabled 2025-08-04 14:58:06 -05:00
cford256
10141504a2 fix: exif rating rounding (#20457)
* fix_Exlif_Metadata_Rating_Rounding_to_Interger

Rounding Exlif Rating Interger
Images support having numbers other than integers for the rating metadata in EXLIF. The database expects it to be an integer though. Trying to upload an image that has a rating other than an integer results in it failing to parse the image and defaulting to showing a corrupted file icon. 

Rather than changing the database type, I would like to round the rating to the nearest integer so that Immich works with images that have a rating like this in their metadata.

* Changing Metadata validateRange to always round.

* Update server/src/services/metadata.service.ts

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-08-04 14:29:51 -05:00
Brandon Wees
67736c8fce fix(mobile): fetch serverConfig before building shared link (#20638)
fix(mobile): fetch serverConfig before trying to pull externalDomain for new shared link
2025-08-04 14:28:43 -05:00
Paweł Wojtaszko
b56a272f64 fix: adjust search bar padding and visibility based on input state (#20598) 2025-08-04 17:46:46 +00:00
shenlong
5901c2e963 fix: hide navigation bar in search page during multi-selection (#20616)
fix: hide navigation bar in search page during multiselect

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-08-04 12:39:40 -05:00
Alex
be85832b20 fix: add assets to album (#20626)
* fix: add assets to album

* always navigate back to the albums view from album page
2025-08-04 12:25:11 -05:00
bo0tzz
c8f9a72d3e feat: close likely duplicates (#20556)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-04 18:15:15 +02:00
Alexandre Garnier
3d633a81c4 fix(mobile): use right translation function for pluralized ICU message format (#20404) 2025-08-04 11:53:11 +05:30
shenlong
4efbf36d82 chore: log asset name on hash failures (#20608) 2025-08-04 06:07:50 +00:00
Alden Bansemer
e2c3c39597 chore: tweak photo sphere fov and zoom speed constants (#20595) 2025-08-04 01:07:11 -05:00
github-actions
007ba1d9ef chore: version v1.137.3 2025-08-01 14:52:24 +00:00
Daniel Dietzler
4d5cd1a6b5 fix: migration if media location is set (#20532) 2025-08-01 14:49:51 +00:00
shenlong
8108f50c4e fix: guard IS_FAVORITE column with SDK check (#20511)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-08-01 05:39:59 -05:00
Alex
1b8354ed36 chore: post release tasks (#20497) 2025-08-01 05:38:52 -05:00
github-actions
9242afb4b0 chore: version v1.137.2 2025-08-01 02:45:16 +00:00
Alex
c5f14adff0 feat: drag to select beta timeline (#20456) 2025-07-31 21:29:01 -05:00
Alex
1378f22368 fix: add to album render empty app bar (#20480)
* fix: add to album render empty app bar

* set current album
2025-07-31 21:28:33 -05:00
Alex
4bd465e752 feat: change grid size with gesture (#20455) 2025-07-31 21:02:28 -05:00
github-actions
a07531be3b chore: version v1.137.1 2025-07-31 23:05:34 +00:00
Daniel Dietzler
3cdc6844a1 fix: automatic media location migration without internal assets (#20489) 2025-07-31 22:58:35 +00:00
github-actions
c3263e50fc chore: version v1.137.0 2025-07-31 20:19:26 +00:00
Alex
7391ea6ff9 chore: large file size grid view styling (#20472)
* chore: large file grid styles

* chore: large file grid styles
2025-07-31 12:52:19 -04:00
Daniel Dietzler
f972b8d514 fix: modal race conditions (#20460) 2025-07-31 07:28:45 -05:00
Jason Rasmussen
6b50d958f4 fix: incorrect next/previous action after folder view refresh (#20447) 2025-07-30 14:50:52 -05:00
Alex
27c456eb75 fix: people navigation (#20450) 2025-07-30 14:47:47 -05:00
Brandon Wees
e7d051db3c feat: drift edit time and date action (#20377)
* feat: drift edit time and date action

* feat: add edit button on asset viewer bottom sheet

* update localDateTime column in addition to createdAt to keep consistency

* fix: dont update local dateTime

Server calcs this anyway and it will be synced when the change is applied. We don't use localDateTime on mobile so there is no reason to update this value

* fix: padding around edit icon in ListTile

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

* chore: format

* fix: hide date edit control when asset does not have a remote

* fix: pull timezones correctly from image

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-07-30 14:40:13 -05:00
Peter Ombodi
86d31d7d29 fix(download): handle completed downloads and refresh albums (#20241)
* fix(download): handle completed downloads and refresh albums

* fix(download): remove use of outdated AlbumService

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2025-07-30 14:33:55 -05:00
shenlong
f416342eff fix: clear local file cache before upload (#20448)
* clear local file cache before upload

* clear cache during hashing

* fix test

* add button to clear cache manually

* add button to clear cache manually

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-07-30 14:32:38 -05:00
Cédric
d73335ecbc docs: add config example for Authelia (#20223) 2025-07-30 19:13:19 +00:00
76 changed files with 1061 additions and 287 deletions

96
.github/workflows/close-duplicates.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
on:
issues:
types: [opened]
discussion:
types: [created]
name: Close likely duplicates
permissions: {}
jobs:
get_body:
runs-on: ubuntu-latest
env:
EVENT: ${{ toJSON(github.event) }}
outputs:
body: ${{ steps.get_body.outputs.body }}
steps:
- id: get_body
run: |
BODY=$(echo """$EVENT""" | jq -r '.issue // .discussion | .body' | base64 -w 0)
echo "body=$BODY" >> $GITHUB_OUTPUT
get_checkbox_json:
runs-on: ubuntu-latest
needs: get_body
container:
image: yshavit/mdq:0.7.2
outputs:
json: ${{ steps.get_checkbox.outputs.json }}
steps:
- id: get_checkbox
env:
BODY: ${{ needs.get_body.outputs.body }}
run: |
JSON=$(echo "$BODY" | base64 -d | /mdq --output json '# I have searched | - [?] Yes')
echo "json=$JSON" >> $GITHUB_OUTPUT
close_and_comment:
runs-on: ubuntu-latest
needs: get_checkbox_json
if: ${{ !fromJSON(needs.get_checkbox_json.outputs.json).items[0].list[0].checked }}
permissions:
issues: write
discussions: write
steps:
- name: Close issue
if: ${{ github.event_name == 'issues' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.issue.node_id }}
run: |
gh api graphql \
-f issueId="$NODE_ID" \
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
-f query='
mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
addComment(input: {
subjectId: $issueId,
body: $body
}) {
__typename
}
closeIssue(input: {
issueId: $issueId,
stateReason: DUPLICATE
}) {
__typename
}
}'
- name: Close discussion
if: ${{ github.event_name == 'discussion' && github.event.discussion.category.name == 'Feature Request' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.discussion.node_id }}
run: |
gh api graphql \
-f discussionId="$NODE_ID" \
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
-f query='
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
addDiscussionComment(input: {
discussionId: $discussionId,
body: $body
}) {
__typename
}
closeDiscussion(input: {
discussionId: $discussionId,
reason: DUPLICATE
}) {
__typename
}
}'

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.73",
"version": "2.2.77",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.73",
"version": "2.2.77",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"chokidar": "^4.0.3",
@@ -54,7 +54,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.136.0",
"version": "1.137.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.73",
"version": "2.2.77",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -64,7 +64,7 @@ Once you have a new OAuth client application configured, Immich can be configure
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (empty for unlimited quota) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
@@ -106,6 +106,89 @@ Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to
## Example Configuration
<details>
<summary>Authelia Example</summary>
### Authelia Example
Here's an example of OAuth configured for Authelia:
This assumes there exist an attribute `immichquota` in the user schema, which is used to set the user's storage quota in Immich.
The configuration concerning the quota is optional.
```yaml
authentication_backend:
ldap:
# The LDAP server configuration goes here.
# See: https://www.authelia.com/c/ldap
attributes:
extra:
immichquota: # The attribute name from LDAP
name: 'immich_quota'
multi_valued: false
value_type: 'integer'
identity_providers:
oidc:
## The other portions of the mandatory OpenID Connect 1.0 configuration go here.
## See: https://www.authelia.com/c/oidc
claims_policies:
immich_policy:
custom_claims:
immich_quota:
attribute: 'immich_quota'
scopes:
immich_scope:
claims:
- 'immich_quota'
clients:
- client_id: 'immich'
client_name: 'Immich'
# https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-a-client-identifier-or-client-secret
client_secret: $pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng'
public: false
require_pkce: false
redirect_uris:
- 'https://example.immich.app/auth/login'
- 'https://example.immich.app/user-settings'
- 'app.immich:///oauth-callback'
scopes:
- 'openid'
- 'profile'
- 'email'
- 'immich_scope'
claims_policy: 'immich_policy'
response_types:
- 'code'
grant_types:
- 'authorization_code'
id_token_signed_response_alg: 'RS256'
userinfo_signed_response_alg: 'RS256'
token_endpoint_auth_method: 'client_secret_post'
```
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------------- | ------------------------------------------------------------------- |
| Issuer URL | `https://example.immich.app/.well-known/openid-configuration` |
| Client ID | immich |
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
| Token Endpoint Auth Method | client_secret_post |
| Scope | openid email profile immich_scope |
| ID Token Signed Response Algorithm | RS256 |
| Userinfo Signed Response Algorithm | RS256 |
| Storage Label Claim | uid |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Authelia (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disable |
| Mobile Redirect URI | |
</details>
<details>
<summary>Authentik Example</summary>
@@ -128,7 +211,7 @@ Configuration of OAuth in Immich System Settings
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Authentik (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
@@ -159,7 +242,7 @@ Configuration of OAuth in Immich System Settings
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Google (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled |

View File

@@ -1,4 +1,20 @@
[
{
"label": "v1.137.3",
"url": "https://v1.137.3.archive.immich.app"
},
{
"label": "v1.137.2",
"url": "https://v1.137.2.archive.immich.app"
},
{
"label": "v1.137.1",
"url": "https://v1.137.1.archive.immich.app"
},
{
"label": "v1.137.0",
"url": "https://v1.137.0.archive.immich.app"
},
{
"label": "v1.136.0",
"url": "https://v1.136.0.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.136.0",
"version": "1.137.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.136.0",
"version": "1.137.3",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -46,7 +46,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.73",
"version": "2.2.77",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -95,7 +95,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.136.0",
"version": "1.137.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.136.0",
"version": "1.137.3",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -653,6 +653,7 @@
"clear": "Clear",
"clear_all": "Clear all",
"clear_all_recent_searches": "Clear all recent searches",
"clear_file_cache": "Clear File Cache",
"clear_message": "Clear message",
"clear_value": "Clear value",
"client_cert_dialog_msg_confirm": "OK",
@@ -834,6 +835,7 @@
"edit_birthday": "Edit Birthday",
"edit_date": "Edit date",
"edit_date_and_time": "Edit date and time",
"edit_date_and_time_action_prompt": "{count} date and time edited",
"edit_description": "Edit description",
"edit_description_prompt": "Please select a new description:",
"edit_exclusion_pattern": "Edit exclusion pattern",
@@ -1250,7 +1252,7 @@
"manage_your_devices": "Manage your logged-in devices",
"manage_your_oauth_connection": "Manage your OAuth connection",
"map": "Map",
"map_assets_in_bounds": "{count, plural, one {# photo} other {# photos}}",
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
"map_cannot_get_user_location": "Cannot get user's location",
"map_location_dialog_yes": "Yes",
"map_location_picker_page_use_location": "Use this location",
@@ -1258,7 +1260,6 @@
"map_location_service_disabled_title": "Location Service disabled",
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
"map_marker_with_image": "Map marker with image",
"map_no_assets_in_bounds": "No photos in this area",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_no_location_permission_title": "Location Permission denied",
"map_settings": "Map settings",

View File

@@ -29,21 +29,24 @@ open class NativeSyncApiImplBase(context: Context) {
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
)
const val BUCKET_SELECTION = "(${MediaStore.Files.FileColumns.BUCKET_ID} = ?)"
val ASSET_PROJECTION = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.DATE_TAKEN,
MediaStore.MediaColumns.DATE_ADDED,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.Files.FileColumns.MEDIA_TYPE,
MediaStore.MediaColumns.BUCKET_ID,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DURATION,
MediaStore.MediaColumns.ORIENTATION,
MediaStore.MediaColumns.IS_FAVORITE,
)
val ASSET_PROJECTION = buildList {
add(MediaStore.MediaColumns._ID)
add(MediaStore.MediaColumns.DATA)
add(MediaStore.MediaColumns.DISPLAY_NAME)
add(MediaStore.MediaColumns.DATE_TAKEN)
add(MediaStore.MediaColumns.DATE_ADDED)
add(MediaStore.MediaColumns.DATE_MODIFIED)
add(MediaStore.Files.FileColumns.MEDIA_TYPE)
add(MediaStore.MediaColumns.BUCKET_ID)
add(MediaStore.MediaColumns.WIDTH)
add(MediaStore.MediaColumns.HEIGHT)
add(MediaStore.MediaColumns.DURATION)
add(MediaStore.MediaColumns.ORIENTATION)
// IS_FAVORITE is only available on Android 11 and above
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE)
}
}.toTypedArray()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
}
@@ -78,7 +81,7 @@ open class NativeSyncApiImplBase(context: Context) {
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
val favoriteColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.IS_FAVORITE)
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
while (c.moveToNext()) {
val id = c.getLong(idColumn).toString()
@@ -107,7 +110,7 @@ open class NativeSyncApiImplBase(context: Context) {
else c.getLong(durationColumn) / 1000
val bucketId = c.getString(bucketIdColumn)
val orientation = c.getInt(orientationColumn)
val isFavorite = c.getInt(favoriteColumn) != 0;
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val asset = PlatformAsset(
id,

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 205,
"android.injected.version.name" => "1.136.0",
"android.injected.version.code" => 3002,
"android.injected.version.name" => "1.137.3",
}
)
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

@@ -649,7 +649,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -793,7 +793,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -823,7 +823,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -857,7 +857,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -900,7 +900,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -940,7 +940,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -979,7 +979,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1023,7 +1023,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1064,7 +1064,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.135.1</string>
<string>1.137.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -105,7 +105,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>210</string>
<string>213</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.136.0"
version_number: "1.137.3"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -96,7 +96,7 @@ class HashService {
if (hash?.length == 20) {
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
} else {
_log.warning("Failed to hash file for ${asset.id}");
_log.warning("Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt}");
}
}
@@ -104,6 +104,7 @@ class HashService {
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
await _localAssetRepository.updateHashes(hashed);
await _storageRepository.clearCache();
}
}

View File

@@ -37,7 +37,7 @@ class BackgroundSyncManager {
this.onHashingError,
});
Future<void> cancel() {
Future<void> cancel() async {
final futures = <Future>[];
if (_syncTask != null) {
@@ -52,7 +52,11 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
return Future.wait(futures);
try {
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
}
// No need to cancel the task, as it can also be run when the user logs out

View File

@@ -186,6 +186,23 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(dateTimeOriginal: Value(dateTime)),
where: (e) => e.assetId.equals(id),
);
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
where: (e) => e.id.equals(id),
);
}
});
}
Future<void> stack(String userId, StackResponse stack) {
return _db.transaction(() async {
final stackIds = await _db.managers.stackEntity

View File

@@ -66,4 +66,14 @@ class StorageRepository {
}
return entity;
}
Future<void> clearCache() async {
final log = Logger('StorageRepository');
try {
await PhotoManager.clearFileCache();
} catch (error, stackTrace) {
log.warning("Error clearing cache", error, stackTrace);
}
}
}

View File

@@ -83,7 +83,6 @@ Future<void> initApp() async {
};
PlatformDispatcher.instance.onError = (error, stack) {
debugPrint("FlutterError - Catch all: $error \n $stack");
log.severe('PlatformDispatcher - Catch all', error, stack);
return true;
};

View File

@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
@@ -14,7 +13,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage()
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
@@ -67,14 +65,14 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList();
final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
handleSyncAlbumToggle(bool isEnable) async {
if (isEnable) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
for (final album in selectedBackupAlbums) {
await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
}
// handleSyncAlbumToggle(bool isEnable) async {
// if (isEnable) {
// await ref.read(albumProvider.notifier).refreshRemoteAlbums();
// for (final album in selectedBackupAlbums) {
// await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
// }
// }
// }
return PopScope(
onPopInvokedWithResult: (didPop, result) async {
@@ -167,16 +165,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
),
),
SettingsSwitchListTile(
valueNotifier: _enableSyncUploadAlbum,
title: "sync_albums".t(context: context),
subtitle: "sync_upload_album_setting_subtitle".t(context: context),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
onChanged: handleSyncAlbumToggle,
),
// SettingsSwitchListTile(
// valueNotifier: _enableSyncUploadAlbum,
// title: "sync_albums".t(context: context),
// subtitle: "sync_upload_album_setting_subtitle".t(context: context),
// contentPadding: const EdgeInsets.symmetric(horizontal: 16),
// titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
// subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
// onChanged: handleSyncAlbumToggle,
// ),
ListTile(
title: Text(
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -9,6 +11,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -122,6 +125,11 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
EventStream.shared.emit(const ScrollToTopEvent());
}
// On Photos page navigation, invalidate memories provider to get the most up-to-date data
if (router.activeIndex == 0) {
ref.invalidate(driftMemoryFutureProvider);
}
// On Search page tapped
if (router.activeIndex == 1 && index == 1) {
ref.read(searchInputFocusProvider).requestFocus();
@@ -137,25 +145,50 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
ref.read(tabProvider.notifier).state = TabEnum.values[index];
}
class _BottomNavigationBar extends ConsumerWidget {
class _BottomNavigationBar extends ConsumerStatefulWidget {
const _BottomNavigationBar({required this.tabsRouter, required this.destinations});
final List<Widget> destinations;
final TabsRouter tabsRouter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScreenLandscape = context.orientation == Orientation.landscape;
final isMultiselectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
ConsumerState createState() => _BottomNavigationBarState();
}
if (isScreenLandscape || isMultiselectEnabled) {
class _BottomNavigationBarState extends ConsumerState<_BottomNavigationBar> {
bool hideNavigationBar = false;
StreamSubscription? _eventSubscription;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen<MultiSelectToggleEvent>(_onEvent);
}
void _onEvent(MultiSelectToggleEvent event) {
setState(() {
hideNavigationBar = event.isEnabled;
});
}
@override
void dispose() {
_eventSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isScreenLandscape = context.orientation == Orientation.landscape;
if (isScreenLandscape || hideNavigationBar) {
return const SizedBox.shrink();
}
return NavigationBar(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) => _onNavigationSelected(tabsRouter, index, ref),
destinations: destinations,
selectedIndex: widget.tabsRouter.activeIndex,
onDestinationSelected: (index) => _onNavigationSelected(widget.tabsRouter, index, ref),
destinations: widget.destinations,
);
}
}

View File

@@ -264,11 +264,15 @@ class SharedLinkEditPage extends HookConsumerWidget {
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
);
ref.invalidate(sharedLinksStateProvider);
await ref.read(serverInfoProvider.notifier).getServerConfig();
final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
if (serverUrl != null && !serverUrl.endsWith('/')) {
serverUrl += '/';
}
if (newLink != null && serverUrl != null) {
newShareLink.value = "${serverUrl}share/${newLink.key}";
copyLinkToClipboard();

View File

@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class MainTimelinePage extends ConsumerWidget {
@@ -12,21 +13,24 @@ class MainTimelinePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
final memoriesEnabled = ref.watch(currentUserProvider.select((user) => user?.memoryEnabled ?? true));
// TODO: the user preferences need to be updated
// from the server to get live hiding/showing of memory lane
return memoryLaneProvider.maybeWhen(
data: (memories) {
return memories.isEmpty
? const Timeline(showStorageIndicator: true)
return memories.isEmpty || !memoriesEnabled
? const Timeline()
: Timeline(
topSliverWidget: SliverToBoxAdapter(
key: Key('memory-lane-${memories.first.assets.first.id}'),
child: DriftMemoryLane(memories: memories),
),
topSliverWidgetHeight: 200,
showStorageIndicator: true,
);
},
orElse: () => const Timeline(showStorageIndicator: true),
orElse: () => const Timeline(),
);
}
}

View File

@@ -28,13 +28,15 @@ class RemoteAlbumPage extends ConsumerStatefulWidget {
}
class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
late RemoteAlbum _album;
@override
void initState() {
super.initState();
_album = widget.album;
}
Future<void> addAssets(BuildContext context) async {
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(widget.album.id);
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id);
final newAssets = await context.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
@@ -47,7 +49,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
final added = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(
widget.album.id,
_album.id,
newAssets.map((asset) {
final remoteAsset = asset as RemoteAsset;
return remoteAsset.id;
@@ -64,14 +66,14 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
Future<void> addUsers(BuildContext context) async {
final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: widget.album));
final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: _album));
if (newUsers == null || newUsers.isEmpty) {
return;
}
try {
await ref.read(remoteAlbumProvider.notifier).addUsers(widget.album.id, newUsers);
await ref.read(remoteAlbumProvider.notifier).addUsers(_album.id, newUsers);
if (newUsers.isNotEmpty) {
ImmichToast.show(
@@ -81,7 +83,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
);
}
ref.invalidate(remoteAlbumSharedUsersProvider(widget.album.id));
ref.invalidate(remoteAlbumSharedUsersProvider(_album.id));
} catch (e) {
ImmichToast.show(
context: context,
@@ -92,7 +94,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
Future<void> toggleAlbumOrder() async {
await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder(widget.album.id);
await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder(_album.id);
ref.invalidate(timelineServiceProvider);
}
@@ -106,7 +108,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('album_delete_confirmation'.t(context: context, args: {'album': widget.album.name})),
Text('album_delete_confirmation'.t(context: context, args: {'album': _album.name})),
const SizedBox(height: 8),
Text('album_delete_confirmation_description'.t(context: context)),
],
@@ -128,7 +130,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
if (confirmed == true) {
try {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(widget.album.id);
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(_album.id);
ImmichToast.show(
context: context,
@@ -151,17 +153,20 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
final result = await showDialog<_EditAlbumData?>(
context: context,
barrierDismissible: true,
builder: (context) => _EditAlbumDialog(album: widget.album),
builder: (context) => _EditAlbumDialog(album: _album),
);
if (result != null && context.mounted) {
setState(() {
_album = _album.copyWith(name: result.name, description: result.description ?? '');
});
HapticFeedback.mediumImpact();
}
}
void showOptionSheet(BuildContext context) {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == widget.album.ownerId : false;
final isOwner = user != null ? user.id == _album.ownerId : false;
showModalBottomSheet(
context: context,
@@ -205,7 +210,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: widget.album.id);
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
@@ -217,7 +222,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context),
),
bottomSheet: RemoteAlbumBottomSheet(album: widget.album),
bottomSheet: RemoteAlbumBottomSheet(album: _album),
),
);
}

View File

@@ -28,7 +28,6 @@ class DriftTrashPage extends StatelessWidget {
}),
],
child: Timeline(
showStorageIndicator: true,
appBar: SliverAppBar(
title: Text('trash'.t(context: context)),
floating: true,

View File

@@ -26,7 +26,6 @@ class LocalTimelinePage extends StatelessWidget {
child: Timeline(
appBar: MesmerizingSliverAppBar(title: album.name),
bottomSheet: const LocalAlbumBottomSheet(),
showStorageIndicator: true,
),
);
}

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
@@ -627,7 +628,12 @@ class _SearchResultGrid extends ConsumerWidget {
return timelineService;
}),
],
child: Timeline(key: ValueKey(searchResult.totalAssets), appBar: null, groupBy: GroupAssetsBy.none),
child: Timeline(
key: ValueKey(searchResult.totalAssets),
groupBy: GroupAssetsBy.none,
appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
),
),
),
);

View File

@@ -1,10 +1,44 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class EditDateTimeActionButton extends ConsumerWidget {
const EditDateTimeActionButton({super.key});
final ActionSource source;
const EditDateTimeActionButton({super.key, required this.source});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_date_and_time_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -12,6 +46,7 @@ class EditDateTimeActionButton extends ConsumerWidget {
maxWidth: 95.0,
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -578,6 +579,8 @@ class AddToAlbumHeader extends ConsumerWidget {
return;
}
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(newAlbum);
ref.read(multiSelectProvider.notifier).reset();
context.pushRoute(RemoteAlbumRoute(album: newAlbum));
}

View File

@@ -143,12 +143,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo);
Future<void> editDateTime() async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
return SliverList.list(
children: [
// Asset Date and Time
_SheetTile(
title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote ? () async => await editDateTime() : null,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
const SheetPeopleDetails(),
@@ -194,11 +200,21 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
class _SheetTile extends StatelessWidget {
final String title;
final Widget? leading;
final Widget? trailing;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final VoidCallback? onTap;
const _SheetTile({required this.title, this.titleStyle, this.leading, this.subtitle, this.subtitleStyle});
const _SheetTile({
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
@@ -234,8 +250,10 @@ class _SheetTile extends StatelessWidget {
title: titleWidget,
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}

View File

@@ -79,7 +79,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
context.back();
return;
}
context.back();
context.pop();
context.pushRoute(DriftPersonRoute(person: person));
},
onNameTap: () => showNameEditModal(person),

View File

@@ -40,7 +40,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View File

@@ -22,13 +22,13 @@ class BaseBottomSheet extends ConsumerStatefulWidget {
this.slivers,
this.controller,
this.initialChildSize = 0.35,
this.minChildSize = 0.15,
double? minChildSize,
this.maxChildSize = 0.65,
this.expand = true,
this.shouldCloseOnMinExtent = true,
this.resizeOnScroll = true,
this.backgroundColor,
});
}) : minChildSize = minChildSize ?? 0.15;
@override
ConsumerState<BaseBottomSheet> createState() => _BaseDraggableScrollableSheetState();

View File

@@ -40,7 +40,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View File

@@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
@@ -26,7 +26,8 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class GeneralBottomSheet extends ConsumerWidget {
const GeneralBottomSheet({super.key});
final double? minChildSize;
const GeneralBottomSheet({super.key, this.minChildSize});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -60,6 +61,7 @@ class GeneralBottomSheet extends ConsumerWidget {
return BaseBottomSheet(
initialChildSize: 0.45,
minChildSize: minChildSize,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
@@ -76,7 +78,7 @@ class GeneralBottomSheet extends ConsumerWidget {
if (multiselect.hasLocal || multiselect.hasMerged) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
],
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View File

@@ -43,7 +43,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View File

@@ -2,10 +2,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class ThumbnailTile extends ConsumerWidget {
@@ -13,7 +15,7 @@ class ThumbnailTile extends ConsumerWidget {
this.asset, {
this.size = const Size.square(256),
this.fit = BoxFit.cover,
this.showStorageIndicator = true,
this.showStorageIndicator,
this.lockSelection = false,
this.heroOffset,
super.key,
@@ -22,7 +24,7 @@ class ThumbnailTile extends ConsumerWidget {
final BaseAsset asset;
final Size size;
final BoxFit fit;
final bool showStorageIndicator;
final bool? showStorageIndicator;
final bool lockSelection;
final int? heroOffset;
@@ -52,6 +54,9 @@ class ThumbnailTile extends ConsumerWidget {
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final bool storageIndicator =
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
return Stack(
children: [
AnimatedContainer(
@@ -86,7 +91,7 @@ class ThumbnailTile extends ConsumerWidget {
child: _VideoIndicator(asset.duration),
),
),
if (showStorageIndicator)
if (storageIndicator)
switch (asset.storage) {
AssetState.local => const Align(
alignment: Alignment.bottomRight,

View File

@@ -1,7 +1,7 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
@@ -11,6 +11,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -125,10 +126,14 @@ class _FixedSegmentRow extends ConsumerWidget {
textDirection: Directionality.of(context),
children: [
for (int i = 0; i < assets.length; i++)
_AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
),
],
);

View File

@@ -14,7 +14,7 @@ class TimelineArgs {
final double maxHeight;
final double spacing;
final int columnCount;
final bool showStorageIndicator;
final bool? showStorageIndicator;
final bool withStack;
final GroupAssetsBy? groupBy;
@@ -23,7 +23,7 @@ class TimelineArgs {
required this.maxHeight,
this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount,
this.showStorageIndicator = false,
this.showStorageIndicator,
this.withStack = false,
this.groupBy,
});

View File

@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
@@ -15,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -27,7 +31,7 @@ class Timeline extends StatelessWidget {
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
this.showStorageIndicator = false,
this.showStorageIndicator,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(),
@@ -36,7 +40,7 @@ class Timeline extends StatelessWidget {
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final bool showStorageIndicator;
final bool? showStorageIndicator;
final Widget? appBar;
final Widget? bottomSheet;
final bool withStack;
@@ -88,10 +92,31 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final _scrollController = ScrollController();
StreamSubscription? _eventSubscription;
// Drag selection state
bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
ScrollPhysics? _scrollPhysics;
int _perRow = 4;
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
setState(() {
_perRow = currentTilesPerRow;
_scaleFactor = 7.0 - _perRow;
_baseScaleFactor = _scaleFactor;
});
});
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
}
void _onEvent(Event event) {
@@ -107,6 +132,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
}
void _onMultiSelectionToggled(_, bool isEnabled) {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
@override
void dispose() {
_scrollController.dispose();
@@ -150,6 +179,71 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
});
}
// Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorIndex = index;
_dragging = true;
});
}
void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() {
_dragging = false;
_draggedAssets.clear();
});
// Reset the scrolling state after a small delay to allow bottom sheet to expand again
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
ref.read(timelineStateProvider.notifier).setScrolling(false);
}
});
}
void _dragScroll(ScrollDirection direction) {
_scrollController.animateTo(
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
duration: const Duration(milliseconds: 125),
curve: Curves.easeOut,
);
}
void _handleDragAssetEnter(TimelineAssetIndex index) {
if (_dragAnchorIndex == null || !_dragging) return;
final timelineService = ref.read(timelineServiceProvider);
final dragAnchorIndex = _dragAnchorIndex!;
// Calculate the range of assets to select
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
final count = endIndex - startIndex + 1;
// Load the assets in the range
if (timelineService.hasRange(startIndex, count)) {
final selectedAssets = timelineService.getAssets(startIndex, count);
// Clear previous drag selection and add new range
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
for (final asset in _draggedAssets) {
multiSelectNotifier.deselectAsset(asset);
}
_draggedAssets.clear();
for (final asset in selectedAssets) {
multiSelectNotifier.selectAsset(asset);
_draggedAssets.add(asset);
}
}
}
@override
Widget build(BuildContext _) {
final asyncSegments = ref.watch(timelineSegmentProvider);
@@ -177,43 +271,83 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
return PrimaryScrollController(
controller: _scrollController,
child: Stack(
children: [
Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
),
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != _perRow) {
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
});
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
}
};
},
),
if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
},
child: TimelineDragRegion(
onStart: _setDragStartIndex,
onAssetEnter: _handleDragAssetEnter,
onEnd: _stopDrag,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
children: [
Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode)
const SelectionSliverAppBar()
else if (widget.appBar != null)
widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
),
),
if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
),
),
),
);
},
@@ -443,3 +577,11 @@ class _MultiSelectStatusButton extends ConsumerWidget {
);
}
}
/// accepts a gesture even though it should reject it (because child won)
class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}

View File

@@ -0,0 +1,212 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class TimelineDragRegion extends StatefulWidget {
final Widget child;
final void Function(TimelineAssetIndex valueKey)? onStart;
final void Function(TimelineAssetIndex valueKey)? onAssetEnter;
final void Function()? onEnd;
final void Function()? onScrollStart;
final void Function(ScrollDirection direction)? onScroll;
const TimelineDragRegion({
super.key,
required this.child,
this.onStart,
this.onAssetEnter,
this.onEnd,
this.onScrollStart,
this.onScroll,
});
@override
State createState() => _TimelineDragRegionState();
}
class _TimelineDragRegionState extends State<TimelineDragRegion> {
late TimelineAssetIndex? assetUnderPointer;
late TimelineAssetIndex? anchorAsset;
// Scroll related state
static const double scrollOffset = 0.10;
double? topScrollOffset;
double? bottomScrollOffset;
Timer? scrollTimer;
late bool scrollNotified;
@override
void initState() {
super.initState();
assetUnderPointer = null;
anchorAsset = null;
scrollNotified = false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
topScrollOffset = null;
bottomScrollOffset = null;
}
@override
void dispose() {
scrollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomLongPressGestureRecognizer>(
() => _CustomLongPressGestureRecognizer(),
_registerCallbacks,
),
},
child: widget.child,
);
}
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
}
TimelineAssetIndex? _getValueKeyAtPosition(Offset position) {
final box = context.findAncestorRenderObjectOfType<RenderBox>();
if (box == null) return null;
final hitTestResult = BoxHitTestResult();
final local = box.globalToLocal(position);
if (!box.hitTest(hitTestResult, position: local)) return null;
return (hitTestResult.path.firstWhereOrNull((hit) => hit.target is _TimelineAssetIndexProxy)?.target
as _TimelineAssetIndexProxy?)
?.index;
}
void _onLongPressStart(LongPressStartDetails event) {
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
final height = context.size?.height;
if (height != null && (topScrollOffset == null || bottomScrollOffset == null)) {
topScrollOffset = height * scrollOffset;
bottomScrollOffset = height - topScrollOffset!;
}
final initialHit = _getValueKeyAtPosition(event.globalPosition);
anchorAsset = initialHit;
if (initialHit == null) return;
if (anchorAsset != null) {
widget.onStart?.call(anchorAsset!);
}
}
void _onLongPressEnd() {
scrollNotified = false;
scrollTimer?.cancel();
widget.onEnd?.call();
}
void _onLongPressMove(LongPressMoveUpdateDetails event) {
if (anchorAsset == null) return;
if (topScrollOffset == null || bottomScrollOffset == null) return;
final currentDy = event.localPosition.dy;
if (currentDy > bottomScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.forward),
);
} else if (currentDy < topScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.reverse),
);
} else {
scrollTimer?.cancel();
scrollTimer = null;
}
final currentlyTouchingAsset = _getValueKeyAtPosition(event.globalPosition);
if (currentlyTouchingAsset == null) return;
if (assetUnderPointer != currentlyTouchingAsset) {
if (!scrollNotified) {
scrollNotified = true;
widget.onScrollStart?.call();
}
widget.onAssetEnter?.call(currentlyTouchingAsset);
assetUnderPointer = currentlyTouchingAsset;
}
}
}
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
class TimelineAssetIndexWrapper extends SingleChildRenderObjectWidget {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndexWrapper({
required Widget super.child,
required this.assetIndex,
required this.segmentIndex,
super.key,
});
@override
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy createRenderObject(BuildContext context) {
return _TimelineAssetIndexProxy(
index: TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex),
);
}
@override
void updateRenderObject(
BuildContext context,
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy renderObject,
) {
renderObject.index = TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex);
}
}
class _TimelineAssetIndexProxy extends RenderProxyBox {
TimelineAssetIndex index;
_TimelineAssetIndexProxy({required this.index});
}
class TimelineAssetIndex {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndex({required this.assetIndex, required this.segmentIndex});
@override
bool operator ==(covariant TimelineAssetIndex other) {
if (identical(this, other)) return true;
return other.assetIndex == assetIndex && other.segmentIndex == segmentIndex;
}
@override
int get hashCode => assetIndex.hashCode ^ segmentIndex.hashCode;
}

View File

@@ -86,11 +86,12 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
// Ensure proper cleanup before starting new background tasks
try {
await Future.wait([
backgroundManager.syncLocal().then((_) {
Future(() async {
await backgroundManager.syncLocal();
Logger("AppLifeCycleNotifier").fine("Hashing assets after syncLocal");
// Check if app is still active before hashing
if (state == AppLifeCycleEnum.resumed) {
backgroundManager.hashAssets();
if ([AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state)) {
await backgroundManager.hashAssets();
}
}),
backgroundManager.syncRemote(),

View File

@@ -1,10 +1,13 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
@@ -30,6 +33,7 @@ class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier');
late ActionService _service;
late UploadService _uploadService;
late DownloadService _downloadService;
ActionNotifier() : super();
@@ -37,6 +41,29 @@ class ActionNotifier extends Notifier<void> {
void build() {
_uploadService = ref.watch(uploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
}
void _downloadImageCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveImageWithPath(update.task);
}
}
void _downloadVideoCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveVideo(update.task);
}
}
void _downloadLivePhotoCallback(TaskStatusUpdate update) async {
if (update.status == TaskStatus.complete) {
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
_downloadService.saveLivePhotos(update.task, livePhotosId);
}
}
List<String> _getRemoteIdsForSource(ActionSource source) {
@@ -239,6 +266,21 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult?> editDateTime(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
final isEdited = await _service.editDateTime(ids, context);
if (!isEdited) {
return null;
}
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to edit date and time for assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source);
try {

View File

@@ -1,8 +1,8 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>(
@@ -10,6 +10,11 @@ final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectSta
dependencies: [timelineServiceProvider],
);
class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
class MultiSelectState {
final Set<BaseAsset> selectedAssets;
final Set<BaseAsset> lockedSelectionAssets;

View File

@@ -66,6 +66,10 @@ class AssetApiRepository extends ApiRepository {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: dateTime.toIso8601String()));
}
Future<StackResponse> stack(List<String> ids) async {
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));

View File

@@ -25,6 +25,8 @@ class AuthApiRepository extends ApiRepository {
}
Future<void> logout() async {
if (_apiService.apiClient.basePath.isEmpty) return;
await _apiService.authenticationApi.logout().timeout(const Duration(seconds: 7));
}

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -159,6 +160,44 @@ class ActionService {
return true;
}
Future<bool> editDateTime(List<String> remoteIds, BuildContext context) async {
DateTime? initialDate;
String? timeZone;
Duration? offset;
if (remoteIds.length == 1) {
final assetId = remoteIds.first;
final asset = await _remoteAssetRepository.get(assetId);
if (asset == null) {
return false;
}
final exifData = await _remoteAssetRepository.getExif(assetId);
initialDate = asset.createdAt.toLocal();
offset = initialDate.timeZoneOffset;
timeZone = exifData?.timeZone;
}
final dateTime = await showDateTimePicker(
context: context,
initialDateTime: initialDate,
initialTZ: timeZone,
initialTZOffset: offset,
);
if (dateTime == null) {
return false;
}
// convert dateTime to DateTime object
final parsedDateTime = DateTime.parse(dateTime);
await _assetApiRepository.updateDateTime(remoteIds, parsedDateTime);
await _remoteAssetRepository.updateDateTime(remoteIds, parsedDateTime);
return true;
}
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
int removedCount = 0;
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);

View File

@@ -99,6 +99,7 @@ class UploadService {
}
Future<void> manualBackup(List<LocalAsset> localAssets) async {
await _storageRepository.clearCache();
List<UploadTask> tasks = [];
for (final asset in localAssets) {
final task = await _getUploadTask(
@@ -120,6 +121,8 @@ class UploadService {
/// Build the upload tasks
/// Enqueue the tasks
Future<void> startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async {
await _storageRepository.clearCache();
shouldAbortQueuingTasks = false;
final candidates = await _backupRepository.getCandidates(userId);
@@ -159,6 +162,7 @@ class UploadService {
Future<int> cancelBackup() async {
shouldAbortQueuingTasks = true;
await _storageRepository.clearCache();
await _uploadRepository.reset(kBackupGroup);
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);

View File

@@ -145,7 +145,7 @@ class _DateTimePicker extends HookWidget {
1,
),
trailing: Icon(Icons.edit_outlined, size: 18, color: context.primaryColor),
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium).tr(),
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium),
onTap: pickDate,
),
const SizedBox(height: 24),

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'dart:ui';
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';
@@ -18,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
@@ -93,7 +95,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
),
onPressed: () {
ref.read(remoteAlbumProvider.notifier).refresh();
context.pop();
context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()]));
},
),
actions: [

View File

@@ -1,4 +1,3 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -26,7 +25,7 @@ class _SelectionSliverAppBarState extends ConsumerState<SelectionSliverAppBar> {
onDone(Set<BaseAsset> selected) {
ref.read(multiSelectProvider.notifier).reset();
context.maybePop<Set<BaseAsset>>(selected);
context.pop<Set<BaseAsset>>(selected);
}
return SliverAppBar(

View File

@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
@@ -229,9 +230,7 @@ class _MapSheetDragRegion extends StatelessWidget {
@override
Widget build(BuildContext context) {
final assetsInBoundsText = assetsInBoundCount > 0
? "map_assets_in_bounds".tr(namedArgs: {'count': assetsInBoundCount.toString()})
: "map_no_assets_in_bounds".tr();
final assetsInBoundsText = "map_assets_in_bounds".t(context: context, args: {'count': assetsInBoundCount});
return SingleChildScrollView(
controller: controller,

View File

@@ -2,11 +2,13 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'asset_list_layout_settings.dart';
class AssetListSettings extends HookConsumerWidget {
@@ -20,7 +22,10 @@ class AssetListSettings extends HookConsumerWidget {
SettingsSwitchListTile(
valueNotifier: showStorageIndicator,
title: 'theme_setting_asset_list_storage_indicator_title'.tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
onChanged: (_) {
ref.invalidate(appSettingsServiceProvider);
ref.invalidate(settingsProvider);
},
),
const LayoutSettings(),
const GroupSettings(),

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
import 'package:path/path.dart' as path;
@@ -104,6 +105,10 @@ class BetaSyncSettings extends HookConsumerWidget {
}
}
Future<void> clearFileCache() async {
await ref.read(storageRepositoryProvider).clearCache();
}
return FutureBuilder<List<dynamic>>(
future: loadCounts(),
builder: (context, snapshot) {
@@ -241,6 +246,14 @@ class BetaSyncSettings extends HookConsumerWidget {
const Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24),
_SectionHeaderText(text: "actions".t(context: context)),
ListTile(
title: Text(
"clear_file_cache".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
leading: const Icon(Icons.playlist_remove_rounded),
onTap: clearFileCache,
),
ListTile(
title: Text(
"export_database".t(context: context),

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.136.0
- API version: 1.137.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.136.0+3000
version: 1.137.3+3002
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -40,6 +40,7 @@ void main() {
registerFallbackValue(LocalAssetStub.image1);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
});
group('HashService hashAssets', () {

View File

@@ -9469,7 +9469,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.136.0",
"version": "1.137.3",
"contact": {}
},
"tags": [],

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.136.0",
"version": "1.137.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.136.0",
"version": "1.137.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.136.0",
"version": "1.137.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.136.0
* 1.137.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.136.0",
"version": "1.137.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.136.0",
"version": "1.137.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.136.0",
"version": "1.137.3",
"description": "",
"author": "",
"private": true,

View File

@@ -170,27 +170,10 @@ where
-- AssetRepository.getFileSamples
select
"asset"."id",
"asset"."originalPath",
"asset"."sidecarPath",
"asset"."encodedVideoPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"path"
from
"asset_file"
where
"asset"."id" = "asset_file"."assetId"
) as agg
) as "files"
"assetId",
"path"
from
"asset"
where
"asset"."libraryId" is null
"asset_file"
limit
3

View File

@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
@@ -338,20 +337,7 @@ export class AssetRepository {
@GenerateSql()
getFileSamples() {
return this.db
.selectFrom('asset')
.select((eb) => [
'asset.id',
'asset.originalPath',
'asset.sidecarPath',
'asset.encodedVideoPath',
jsonArrayFrom(eb.selectFrom('asset_file').select('path').whereRef('asset.id', '=', 'asset_file.assetId')).as(
'files',
),
])
.where('asset.libraryId', 'is', null)
.limit(sql.lit(3))
.execute();
return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })

View File

@@ -86,12 +86,7 @@ export class CliService extends BaseService {
}
for (const asset of assets) {
paths.push(
asset.originalPath,
asset.sidecarPath,
asset.encodedVideoPath,
...asset.files.map((file) => file.path),
);
paths.push(asset.path);
}
return paths.filter(Boolean) as string[];

View File

@@ -101,7 +101,7 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
return null;
}
return val;
return Math.round(val);
};
const getLensModel = (exifTags: ImmichTags): string | null => {

View File

@@ -97,18 +97,22 @@ export class StorageService extends BaseService {
const current = StorageCore.getMediaLocation();
const samples = await this.assetRepository.getFileSamples();
if (samples.length > 0) {
const originalPath = samples[0].originalPath;
const path = samples[0].path;
const savedValue = await this.systemMetadataRepository.get(SystemMetadataKey.MediaLocation);
let previous = savedValue?.location || '';
if (!previous && this.configRepository.getEnv().storage.mediaLocation) {
previous = current;
}
if (!previous) {
previous = originalPath.startsWith('upload/') ? 'upload' : '/usr/src/app/upload';
previous = path.startsWith('upload/') ? 'upload' : '/usr/src/app/upload';
}
if (previous !== current) {
this.logger.log(`Media location changed (from=${previous}, to=${current})`);
if (!originalPath.startsWith(previous)) {
if (!path.startsWith(previous)) {
throw new Error(
'Detected an inconsistent media location. For more information, see https://immich.app/errors#inconsistent-media-location',
);

14
web/package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "immich-web",
"version": "1.136.0",
"version": "1.137.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.136.0",
"version": "1.137.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.23.5",
"@immich/ui": "^0.23.6",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
@@ -94,7 +94,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.136.0",
"version": "1.137.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
@@ -1357,9 +1357,9 @@
"link": true
},
"node_modules/@immich/ui": {
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.5.tgz",
"integrity": "sha512-1wlFMmfDmtGC+Kcc8cYTT00mQaSumR41KEOOOmVn5Rw/8z9pUhpNY8mGl1AxY4qhtnaz+G3dH6vowYzL23D+YQ==",
"version": "0.23.6",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.6.tgz",
"integrity": "sha512-HYIguDx/nCXcvqLKhY1R/+Aks6mn8B9jIiNVQH6WODDPbvGFrvQT5uINhXHrjsdyuzKBVS6dps+lx9+9Z6z4rA==",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@mdi/js": "^7.4.47",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.136.0",
"version": "1.137.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -28,7 +28,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.23.5",
"@immich/ui": "^0.23.6",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",

View File

@@ -63,8 +63,9 @@
touchmoveTwoFingers: false,
mousewheelCtrlKey: false,
navbar,
minFov: 10,
maxFov: 120,
minFov: 15,
maxFov: 90,
zoomSpeed: 0.5,
fisheye: false,
});
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;

View File

@@ -97,7 +97,7 @@
{@render children?.()}
</div>
<div class="me-4 flex place-items-center gap-1 justify-self-end">
<div class="max-[350px]:me-0 max-[350px]:gap-0 me-4 flex place-items-center gap-1 justify-self-end">
{@render trailing?.()}
</div>
</nav>

View File

@@ -27,6 +27,7 @@
import Portal from '../portal/portal.svelte';
interface Props {
initialAssetId?: string;
assets: (TimelineAsset | AssetResponseDto)[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
@@ -44,6 +45,7 @@
}
let {
initialAssetId = undefined,
assets = $bindable(),
assetInteraction,
disableAssetSelect = false,
@@ -117,7 +119,14 @@
};
});
let currentViewAssetIndex = 0;
let currentIndex = 0;
if (initialAssetId && assets.length > 0) {
const index = assets.findIndex(({ id }) => id === initialAssetId);
if (index !== -1) {
currentIndex = index;
}
}
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let slidingWindow = $state({ top: 0, bottom: 0 });
@@ -150,8 +159,8 @@
}
});
const viewAssetHandler = async (asset: TimelineAsset) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
await setAssetId(assets[currentViewAssetIndex].id);
currentIndex = assets.findIndex((a) => a.id == asset.id);
await setAssetId(assets[currentIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
};
@@ -324,12 +333,12 @@
if (onNext) {
asset = await onNext();
} else {
if (currentViewAssetIndex >= assets.length - 1) {
if (currentIndex >= assets.length - 1) {
return false;
}
currentViewAssetIndex = currentViewAssetIndex + 1;
asset = currentViewAssetIndex < assets.length ? assets[currentViewAssetIndex] : undefined;
currentIndex = currentIndex + 1;
asset = currentIndex < assets.length ? assets[currentIndex] : undefined;
}
if (!asset) {
@@ -374,12 +383,12 @@
if (onPrevious) {
asset = await onPrevious();
} else {
if (currentViewAssetIndex <= 0) {
if (currentIndex <= 0) {
return false;
}
currentViewAssetIndex = currentViewAssetIndex - 1;
asset = currentViewAssetIndex >= 0 ? assets[currentViewAssetIndex] : undefined;
currentIndex = currentIndex - 1;
asset = currentIndex >= 0 ? assets[currentIndex] : undefined;
}
if (!asset) {
@@ -412,10 +421,10 @@
);
if (assets.length === 0) {
await goto(AppRoute.PHOTOS);
} else if (currentViewAssetIndex === assets.length) {
} else if (currentIndex === assets.length) {
await handlePrevious();
} else {
await setAssetId(assets[currentViewAssetIndex].id);
await setAssetId(assets[currentIndex].id);
}
break;
}

View File

@@ -230,7 +230,8 @@
type="text"
name="q"
id="main-search-bar"
class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
class="w-full transition-all border-2 ps-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
{showClearIcon ? 'pe-[90px]' : 'pe-14'}
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
{searchStore.isSearchEnabled ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
@@ -285,6 +286,7 @@
{#if isFocus}
<div
class="absolute inset-y-0 flex items-center"
class:max-md:hidden={value}
class:end-16={isFocus}
class:end-28={isFocus && value.length > 0}
>

View File

@@ -1,12 +1,8 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { getFileSize } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
@@ -16,43 +12,26 @@
let { asset, onViewAsset }: Props = $props();
let assetData = $derived(JSON.stringify(asset, null, 2));
let boxWidth = $state(300);
</script>
<div
class="max-w-60 rounded-xl border-4 transition-colors font-semibold text-xs bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800"
class="w-full aspect-square rounded-xl border-4 transition-colors font-semibold text-xs bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800"
bind:clientWidth={boxWidth}
title={assetData}
>
<div class="relative w-full">
<button type="button" onclick={() => onViewAsset(asset)} class="block relative w-full" aria-label={$t('keep')}>
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id)}
alt={$getAltText(toTimelineAsset(asset))}
title={assetData}
class="h-60 object-cover rounded-t-xl w-full"
draggable="false"
/>
<div class="relative w-full h-full overflow-hidden rounded-lg">
<Thumbnail asset={toTimelineAsset(asset)} readonly onClick={() => onViewAsset(asset)} thumbnailSize={boxWidth} />
<!-- OVERLAY CHIP -->
{#if !!asset.libraryId}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-300/90">External</div>
{/if}
<!-- FAVORITE ICON -->
{#if asset.isFavorite}
<div class="absolute bottom-2 start-2">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
</button>
{#if !!asset.libraryId}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-500">External</div>
{/if}
</div>
<div class="flex justify-between items-center pl-2 pr-4 gap-2">
<div class="grid gap-y-2 py-2 text-xs transition-colors dark:text-white">
<div class="text-left text-ellipsis truncate">{asset.originalFileName}</div>
<span>{getAssetResolution(asset)}</span>
</div>
<div class="dark:text-white text-lg font-bold whitespace-nowrap w-max">
{getFileSize(asset, 1)}
</div>
<div class="text-center mt-4 px-4 text-sm font-normal truncate" title={asset.originalFileName}>
{asset.originalFileName}
</div>
<div class="text-center">
<p class="text-primary text-xl font-semibold py-3">{getFileSize(asset, 1)}</p>
</div>
</div>

View File

@@ -21,8 +21,8 @@
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
@@ -40,7 +40,6 @@
let { data }: Props = $props();
const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction();
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
@@ -104,6 +103,7 @@
{#if data.pathAssets && data.pathAssets.length > 0}
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
<GalleryViewer
initialAssetId={data.asset?.id}
assets={data.pathAssets}
{assetInteraction}
{viewport}

View File

@@ -56,7 +56,7 @@
</script>
<UserPageLayout title={data.meta.title} scrollbar={true}>
<div class="flex gap-2 flex-wrap">
<div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{#if assets && data.assets.length > 0}
{#each assets as asset (asset.id)}
<LargeAssetData {asset} onViewAsset={(asset) => setAsset(asset)} />