From e9dafefb024dcb582f1e9a816b1eccec3d1eb5e3 Mon Sep 17 00:00:00 2001 From: izzy Date: Fri, 16 Jan 2026 09:43:30 +0000 Subject: [PATCH] merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy --- i18n/en.json | 4 +- .../drift_schemas/main/drift_schema_v16.json | 2 +- mobile/ios/Runner/AppDelegate.swift | 1 + .../Connectivity/ConnectivityApiImpl.swift | 56 +- .../services/background_worker.service.dart | 12 +- .../remote_asset_cloud_id.entity.dart | 2 +- .../remote_asset_cloud_id.entity.drift.dart | 1 - .../repositories/db.repository.steps.dart | 1 - .../repositories/storage.repository.dart | 49 +- .../upload/share_intent_attachment.model.dart | 2 +- .../lib/pages/backup/drift_backup.page.dart | 4 +- .../drift_backup_album_selection.page.dart | 4 +- .../backup/drift_backup_options.page.dart | 4 +- .../backup/drift_upload_detail.page.dart | 601 ++++++++++++++---- .../lib/pages/common/splash_screen.page.dart | 2 +- .../pages/share_intent/share_intent.page.dart | 36 +- .../pages/editing/drift_edit.page.dart | 5 +- .../upload_action_button.widget.dart | 77 ++- .../asset_viewer/video_viewer.widget.dart | 2 +- .../backup/backup_toggle_button.widget.dart | 40 +- .../widgets/images/thumbnail_tile.widget.dart | 49 ++ .../providers/app_life_cycle.provider.dart | 4 +- .../share_intent_upload.provider.dart | 146 ++--- mobile/lib/providers/auth.provider.dart | 8 +- .../asset_upload_progress.provider.dart | 33 + .../backup/drift_backup.provider.dart | 329 ++++------ .../infrastructure/action.provider.dart | 48 +- .../infrastructure/storage.provider.dart | 2 +- .../lib/repositories/upload.repository.dart | 144 +++-- ...ce.dart => background_upload.service.dart} | 289 +++------ .../services/foreground_upload.service.dart | 461 ++++++++++++++ mobile/lib/utils/upload_speed_calculator.dart | 182 ++++++ mobile/test/domain/service.mock.dart | 4 +- .../test/drift/main/generated/schema_v16.dart | 1 - mobile/test/services/auth.service_test.dart | 4 - ...rt => background_upload.service_test.dart} | 102 +-- pnpm-lock.yaml | 511 +++++++-------- web/package.json | 2 +- web/src/lib/actions/thumbhash.ts | 34 +- web/src/lib/components/AdminSidebar.svelte | 22 - .../components/BreadcrumbActionPage.svelte | 61 ++ .../components/layouts/AdminPageLayout.svelte | 87 +-- .../lib/components/layouts/PageContent.svelte | 12 - .../layouts/user-page-layout.svelte | 8 +- .../navigation-bar/navigation-bar.svelte | 9 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 2 +- 46 files changed, 2234 insertions(+), 1225 deletions(-) create mode 100644 mobile/lib/providers/backup/asset_upload_progress.provider.dart rename mobile/lib/services/{upload.service.dart => background_upload.service.dart} (75%) create mode 100644 mobile/lib/services/foreground_upload.service.dart create mode 100644 mobile/lib/utils/upload_speed_calculator.dart rename mobile/test/services/{upload.service_test.dart => background_upload.service_test.dart} (82%) delete mode 100644 web/src/lib/components/AdminSidebar.svelte create mode 100644 web/src/lib/components/BreadcrumbActionPage.svelte delete mode 100644 web/src/lib/components/layouts/PageContent.svelte diff --git a/i18n/en.json b/i18n/en.json index ea0df37cb8..2d2f270e44 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -949,6 +949,7 @@ "download_waiting_to_retry": "Waiting to retry", "downloading": "Downloading", "downloading_asset_filename": "Downloading asset {filename}", + "downloading_from_icloud": "Downloading from iCloud", "downloading_media": "Downloading media", "drop_files_to_upload": "Drop files anywhere to upload", "duplicates": "Duplicates", @@ -1134,6 +1135,7 @@ "unable_to_update_workflow": "Unable to update workflow", "unable_to_upload_file": "Unable to upload file" }, + "errors_text": "Errors", "exclusion_pattern": "Exclusion pattern", "exif": "Exif", "exif_bottom_sheet_description": "Add Description...", @@ -2139,7 +2141,6 @@ "sync": "Sync", "sync_albums": "Sync albums", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", - "sync_cloud_ids": "Sync Cloud IDs", "sync_local": "Sync Local", "sync_remote": "Sync Remote", "sync_status": "Sync Status", @@ -2252,7 +2253,6 @@ "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", - "upload_action_prompt": "{count} queued for upload", "upload_concurrency": "Upload concurrency", "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", diff --git a/mobile/drift_schemas/main/drift_schema_v16.json b/mobile/drift_schemas/main/drift_schema_v16.json index 8e716ada0d..417a3a0f20 100644 --- a/mobile/drift_schemas/main/drift_schema_v16.json +++ b/mobile/drift_schemas/main/drift_schema_v16.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"i_cloud_id","getter_name":"iCloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":8,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)","unique":false,"columns":[]}},{"id":9,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":10,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":12,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":13,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":14,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":15,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":16,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":17,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":18,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":19,"references":[1],"type":"table","data":{"name":"remote_asset_cloud_id_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"cloud_id","getter_name":"cloudId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"UNIQUE","dialectAwareDefaultConstraints":{"sqlite":"UNIQUE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":21,"references":[1,20],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":22,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":23,"references":[1,22],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":24,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":25,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"source","getter_name":"source","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TrashOrigin.values)","dart_type_name":"TrashOrigin"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":26,"references":[16],"type":"index","data":{"on":16,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":27,"references":[25],"type":"index","data":{"on":25,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":28,"references":[25],"type":"index","data":{"on":25,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"i_cloud_id","getter_name":"iCloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":8,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)","unique":false,"columns":[]}},{"id":9,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":10,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":12,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":13,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":14,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":15,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":16,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":17,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":18,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":19,"references":[1],"type":"table","data":{"name":"remote_asset_cloud_id_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"cloud_id","getter_name":"cloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":21,"references":[1,20],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":22,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":23,"references":[1,22],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":24,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":25,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"source","getter_name":"source","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TrashOrigin.values)","dart_type_name":"TrashOrigin"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":26,"references":[16],"type":"index","data":{"on":16,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":27,"references":[25],"type":"index","data":{"on":25,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":28,"references":[25],"type":"index","data":{"on":25,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}}]} \ No newline at end of file diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 4e4cb2ed13..108fb7e2aa 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -55,6 +55,7 @@ import UIKit NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!) ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) + ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl()) } public static func cancelPlugins(with engine: FlutterEngine) { diff --git a/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift index 0261cb26fb..f104314fae 100644 --- a/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift +++ b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift @@ -1,6 +1,60 @@ +import Network class ConnectivityApiImpl: ConnectivityApi { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "ConnectivityMonitor") + private var currentPath: NWPath? + + init() { + monitor.pathUpdateHandler = { [weak self] path in + self?.currentPath = path + } + monitor.start(queue: queue) + // Get initial state synchronously + currentPath = monitor.currentPath + } + + deinit { + monitor.cancel() + } + func getCapabilities() throws -> [NetworkCapability] { - [] + guard let path = currentPath else { + return [] + } + + guard path.status == .satisfied else { + return [] + } + + var capabilities: [NetworkCapability] = [] + + if path.usesInterfaceType(.wifi) { + capabilities.append(.wifi) + } + + if path.usesInterfaceType(.cellular) { + capabilities.append(.cellular) + } + + // Check for VPN - iOS reports VPN as .other interface type in many cases + // or through the path's expensive property when on cellular with VPN + if path.usesInterfaceType(.other) { + capabilities.append(.vpn) + } + + // Determine if connection is unmetered: + // - Must be on WiFi (not cellular) + // - Must not be expensive (rules out personal hotspot) + // - Must not be constrained (Low Data Mode) + // Note: VPN over cellular should still be considered metered + let isOnCellular = path.usesInterfaceType(.cellular) + let isOnWifi = path.usesInterfaceType(.wifi) + + if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained { + capabilities.append(.unmetered) + } + + return capabilities } } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 8a237f801a..9019db664d 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; @@ -20,13 +19,13 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; @@ -243,13 +242,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } if (Platform.isIOS) { - return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); + return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id); } - final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? []; return _ref - ?.read(uploadServiceProvider) - .startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken); + ?.read(foregroundUploadServiceProvider) + .uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true); }, (error, stack) { dPrint(() => "Error in backup zone $error, $stack"); diff --git a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart index ccebbf635a..332a38a690 100644 --- a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin { TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); - TextColumn get cloudId => text().unique().nullable()(); + TextColumn get cloudId => text().nullable()(); DateTimeColumn get createdAt => dateTime().nullable()(); diff --git a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart index f3c416ad3a..8b15fb8f47 100644 --- a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart @@ -438,7 +438,6 @@ class $RemoteAssetCloudIdEntityTable extends i2.RemoteAssetCloudIdEntity true, type: i0.DriftSqlType.string, requiredDuringInsert: false, - defaultConstraints: i0.GeneratedColumn.constraintIsAlways('UNIQUE'), ); static const i0.VerificationMeta _createdAtMeta = const i0.VerificationMeta( 'createdAt', diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 6e46e3e13e..10cba0821a 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -6903,7 +6903,6 @@ i1.GeneratedColumn _column_99(String aliasedName) => aliasedName, true, type: i1.DriftSqlType.string, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'), ); i1.GeneratedColumn _column_100(String aliasedName) => i1.GeneratedColumn( diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 9532025d58..eaa6ce79f7 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -6,7 +6,9 @@ import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; class StorageRepository { - const StorageRepository(); + final log = Logger('StorageRepository'); + + StorageRepository(); Future getFileForAsset(String assetId) async { File? file; @@ -82,6 +84,51 @@ class StorageRepository { return entity; } + Future isAssetAvailableLocally(String assetId) async { + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return false; + } + + return await entity.isLocallyAvailable(isOrigin: true); + } catch (error, stackTrace) { + log.warning("Error checking if asset is locally available $assetId", error, stackTrace); + return false; + } + } + + Future loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return null; + } + + return await entity.loadFile(progressHandler: progressHandler); + } catch (error, stackTrace) { + log.warning("Error loading file from cloud for asset $assetId", error, stackTrace); + return null; + } + } + + Future loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return null; + } + + return await entity.loadFile(withSubtype: true, progressHandler: progressHandler); + } catch (error, stackTrace) { + log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace); + return null; + } + } + Future clearCache() async { final log = Logger('StorageRepository'); diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index ae05e4c492..e5388fce2c 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -7,7 +7,7 @@ import 'package:path/path.dart'; enum ShareIntentAttachmentType { image, video } -enum UploadStatus { enqueued, running, complete, notFound, failed, canceled, waitingToRetry, paused } +enum UploadStatus { enqueued, running, complete, failed } class ShareIntentAttachment { final String path; diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 47052ea436..440544f989 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -93,11 +93,11 @@ class _DriftBackupPageState extends ConsumerState { Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup"); return; } - await backupNotifier.startBackup(currentUser.id); + await backupNotifier.startForegroundBackup(currentUser.id); } Future stopBackup() async { - await backupNotifier.cancel(); + await backupNotifier.stopForegroundBackup(); } return Scaffold( diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 5fe1dfb6a1..93ab659032 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -113,10 +113,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets())); if (isBackupEnabled) { unawaited( - backupNotifier.cancel().whenComplete( + backupNotifier.stopForegroundBackup().whenComplete( () => backgroundSync.syncRemote().then((success) { if (success) { - return backupNotifier.startBackup(user.id); + return backupNotifier.startForegroundBackup(user.id); } else { Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); } diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index 1e5c326478..f43c8b6a8e 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -60,10 +60,10 @@ class DriftBackupOptionsPage extends ConsumerWidget { final backupNotifier = ref.read(driftBackupProvider.notifier); final backgroundSync = ref.read(backgroundSyncProvider); unawaited( - backupNotifier.cancel().whenComplete( + backupNotifier.stopForegroundBackup().whenComplete( () => backgroundSync.syncRemote().then((success) { if (success) { - return backupNotifier.startBackup(currentUser.id); + return backupNotifier.startForegroundBackup(currentUser.id); } else { Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); } diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 612b6a8111..71249d1c4b 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -11,12 +11,70 @@ import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:path/path.dart' as path; @RoutePage() -class DriftUploadDetailPage extends ConsumerWidget { +class DriftUploadDetailPage extends ConsumerStatefulWidget { const DriftUploadDetailPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DriftUploadDetailPageState(); +} + +class _DriftUploadDetailPageState extends ConsumerState { + final Set _seenTaskIds = {}; + final Set _failedTaskIds = {}; + + final Map _taskSlotAssignments = {}; + static const int _maxSlots = 3; + + /// Assigns uploading items to fixed slots to prevent jumping when items complete + List _assignItemsToSlots(List uploadingItems) { + final slots = List.filled(_maxSlots, null); + final currentTaskIds = uploadingItems.map((e) => e.taskId).toSet(); + + _taskSlotAssignments.removeWhere((taskId, _) => !currentTaskIds.contains(taskId)); + + for (final item in uploadingItems) { + final existingSlot = _taskSlotAssignments[item.taskId]; + if (existingSlot != null && existingSlot < _maxSlots) { + slots[existingSlot] = item; + } + } + + for (final item in uploadingItems) { + if (_taskSlotAssignments.containsKey(item.taskId)) continue; + + for (int i = 0; i < _maxSlots; i++) { + if (slots[i] == null) { + slots[i] = item; + _taskSlotAssignments[item.taskId] = i; + break; + } + } + } + + return slots; + } + + @override + Widget build(BuildContext context) { final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems)); + final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress)); + + for (final item in uploadItems.values) { + if (item.isFailed == true) { + _failedTaskIds.add(item.taskId); + } + } + + for (final item in uploadItems.values) { + if (item.progress >= 1.0 && item.isFailed != true && !_failedTaskIds.contains(item.taskId)) { + if (!_seenTaskIds.contains(item.taskId)) { + _seenTaskIds.add(item.taskId); + } + } + } + + final uploadingItems = uploadItems.values.where((item) => item.progress < 1.0 && item.isFailed != true).toList(); + final failedItems = uploadItems.values.where((item) => item.isFailed == true).toList(); return Scaffold( appBar: AppBar( @@ -25,98 +83,326 @@ class DriftUploadDetailPage extends ConsumerWidget { elevation: 0, scrolledUnderElevation: 1, ), - body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems), + body: _buildTwoSectionLayout(context, uploadingItems, failedItems, iCloudProgress), ); } - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.cloud_off_rounded, size: 80, color: context.colorScheme.onSurface.withValues(alpha: 0.3)), - const SizedBox(height: 16), - Text( - "no_uploads_in_progress".t(context: context), - style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.6)), + Widget _buildTwoSectionLayout( + BuildContext context, + List uploadingItems, + List failedItems, + Map iCloudProgress, + ) { + return CustomScrollView( + slivers: [ + // iCloud Downloads Section + if (iCloudProgress.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "Downloading from iCloud", + count: iCloudProgress.length, + color: context.colorScheme.tertiary, + ), ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = iCloudProgress.entries.elementAt(index); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildICloudDownloadCard(context, entry.key, entry.value), + ); + }, childCount: iCloudProgress.length), + ), + ), + ], + + // Uploading Section + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "uploading".t(context: context), + count: uploadingItems.length, + color: context.colorScheme.primary, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + // Use slot-based assignment to prevent items from jumping + final slots = _assignItemsToSlots(uploadingItems); + final item = slots[index]; + if (item != null) { + return _buildCurrentUploadCard(context, item); + } else { + return _buildPlaceholderCard(context); + } + }, childCount: 3), + ), + ), + + // Errors Section + if (failedItems.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "errors_text".t(context: context), + count: failedItems.length, + color: context.colorScheme.error, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = failedItems[index]; + return Padding(padding: const EdgeInsets.only(bottom: 8), child: _buildErrorCard(context, item)); + }, childCount: failedItems.length), + ), + ), + ], + + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, {required String title, int? count, required Color color}) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600, color: color), + ), + const SizedBox(width: 8), + count != null + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Text( + count.toString(), + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, color: color), + ), + ) + : const SizedBox.shrink(), ], ), ); } - Widget _buildUploadList(Map uploadItems) { - return ListView.separated( - addAutomaticKeepAlives: true, - padding: const EdgeInsets.all(16), - itemCount: uploadItems.length, - separatorBuilder: (context, index) => const SizedBox(height: 4), - itemBuilder: (context, index) { - final item = uploadItems.values.elementAt(index); - return _buildUploadCard(context, item); - }, - ); - } - - Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) { - final isCompleted = item.progress >= 1.0; - final double progressPercentage = (item.progress * 100).clamp(0, 100); + Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) { + final double progressPercentage = (progress * 100).clamp(0, 100); return Card( elevation: 0, - color: item.isFailed != null ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer, + color: context.colorScheme.tertiaryContainer.withValues(alpha: 0.5), shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(16)), - side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1), + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: context.colorScheme.tertiary.withValues(alpha: 0.3), width: 1), ), - child: InkWell( - onTap: () => _showFileDetailDialog(context, item), - borderRadius: const BorderRadius.all(Radius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.colorScheme.tertiary.withValues(alpha: 0.2), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Icon(Icons.cloud_download_rounded, size: 24, color: context.colorScheme.tertiary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - Text( - path.basename(item.filename), - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (item.error != null) - Text( - item.error!, - style: context.textTheme.bodySmall?.copyWith( - color: context.colorScheme.onErrorContainer.withValues(alpha: 0.6), - ), - ), - Text( - "backup_upload_details_page_more_details".t(context: context), - style: context.textTheme.bodySmall?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), + Text( + "downloading_from_icloud".t(context: context), + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - _buildProgressIndicator( - context, - item.progress, - progressPercentage, - isCompleted, - item.networkSpeedAsString, + const SizedBox(height: 4), + Text( + assetId, + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: LinearProgressIndicator( + value: progress, + backgroundColor: context.colorScheme.tertiary.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(context.colorScheme.tertiary), + minHeight: 4, + ), ), ], ), + ), + const SizedBox(width: 12), + SizedBox( + width: 48, + child: Text( + "${progressPercentage.toStringAsFixed(0)}%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.tertiary, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCurrentUploadCard(BuildContext context, DriftUploadStatus item) { + final double progressPercentage = (item.progress * 100).clamp(0, 100); + final isFailed = item.isFailed == true; + + return Card( + elevation: 0, + color: isFailed + ? context.colorScheme.errorContainer + : context.colorScheme.primaryContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide( + color: isFailed + ? context.colorScheme.error.withValues(alpha: 0.3) + : context.colorScheme.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: InkWell( + onTap: () => _showFileDetailDialog(context, item), + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + height: 64, + child: Row( + children: [ + _CurrentUploadThumbnail(taskId: item.taskId), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + path.basename(item.filename), + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + isFailed + ? item.error ?? "unable_to_upload_file".t(context: context) + : "${formatHumanReadableBytes(item.fileSize, 1)} • ${item.networkSpeedAsString}", + style: context.textTheme.labelLarge?.copyWith( + color: isFailed + ? context.colorScheme.error + : context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (!isFailed) ...[ + const SizedBox(height: 8), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: LinearProgressIndicator( + value: item.progress, + backgroundColor: context.colorScheme.primary.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(context.colorScheme.primary), + minHeight: 4, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 48, + child: isFailed + ? Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28) + : Text( + "${progressPercentage.toStringAsFixed(0)}%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildErrorCard(BuildContext context, DriftUploadStatus item) { + return Card( + elevation: 0, + color: context.colorScheme.errorContainer, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: context.colorScheme.error.withValues(alpha: 0.3), width: 1), + ), + child: InkWell( + onTap: () => _showFileDetailDialog(context, item), + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _CurrentUploadThumbnail(taskId: item.taskId), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + path.basename(item.filename), + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + item.error ?? "unable_to_upload_file".t(context: context), + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error), + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28), ], ), ), @@ -124,49 +410,84 @@ class DriftUploadDetailPage extends ConsumerWidget { ); } - Widget _buildProgressIndicator( - BuildContext context, - double progress, - double percentage, - bool isCompleted, - String networkSpeedAsString, - ) { - return Column( - children: [ - Stack( - alignment: AlignmentDirectional.center, - children: [ - SizedBox( - width: 36, - height: 36, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: progress), - duration: const Duration(milliseconds: 300), - builder: (context, value, _) => CircularProgressIndicator( - backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 3, - value: value, - color: isCompleted ? context.colorScheme.primary : context.colorScheme.secondary, + Widget _buildPlaceholderCard(BuildContext context) { + return Card( + elevation: 0, + color: context.colorScheme.surfaceContainerLow.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1, style: BorderStyle.solid), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + height: 64, + child: Row( + children: [ + SizedBox( + width: 48, + height: 48, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Icon( + Icons.hourglass_empty_rounded, + size: 24, + color: context.colorScheme.outline.withValues(alpha: 0.3), + ), ), ), - ), - if (isCompleted) - Icon(Icons.check_circle_rounded, size: 28, color: context.colorScheme.primary) - else - Text( - percentage.toStringAsFixed(0), - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, + width: 120, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + const SizedBox(height: 6), + Container( + height: 10, + width: 80, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.08), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + const SizedBox(height: 8), + Container( + height: 4, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + ], + ), ), - ], - ), - Text( - networkSpeedAsString, - style: context.textTheme.labelSmall?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - fontSize: 10, + const SizedBox(width: 12), + SizedBox( + width: 48, + child: Text( + "0%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + ), + ], ), ), - ], + ), ); } @@ -178,9 +499,44 @@ class DriftUploadDetailPage extends ConsumerWidget { } } +class _CurrentUploadThumbnail extends ConsumerWidget { + final String taskId; + const _CurrentUploadThumbnail({required this.taskId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return FutureBuilder( + future: _getAsset(ref), + builder: (context, snapshot) { + return SizedBox( + width: 48, + height: 48, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.primary.withValues(alpha: 0.2), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: snapshot.data != null + ? Thumbnail.fromAsset(asset: snapshot.data!, size: const Size(48, 48), fit: BoxFit.cover) + : Icon(Icons.image, size: 24, color: context.colorScheme.primary), + ), + ); + }, + ); + } + + Future _getAsset(WidgetRef ref) async { + try { + return await ref.read(localAssetRepository).getById(taskId); + } catch (e) { + return null; + } + } +} + class FileDetailDialog extends ConsumerWidget { final DriftUploadStatus uploadStatus; - const FileDetailDialog({super.key, required this.uploadStatus}); @override @@ -212,14 +568,12 @@ class FileDetailDialog extends ConsumerWidget { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator())); } - final asset = snapshot.data; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // Thumbnail at the top center Center( child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(12)), @@ -237,7 +591,7 @@ class FileDetailDialog extends ConsumerWidget { ), ), const SizedBox(height: 24), - if (asset != null) ...[ + if (asset != null) _buildInfoSection(context, [ _buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)), _buildInfoRow(context, "local_id".t(context: context), asset.id), @@ -254,7 +608,6 @@ class FileDetailDialog extends ConsumerWidget { if (asset.checksum != null) _buildInfoRow(context, "checksum".t(context: context), asset.checksum!), ]), - ], ], ), ); @@ -282,7 +635,7 @@ class FileDetailDialog extends ConsumerWidget { borderRadius: const BorderRadius.all(Radius.circular(12)), border: Border.all(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1), ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [...children]), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), ); } @@ -303,12 +656,7 @@ class FileDetailDialog extends ConsumerWidget { ), ), Expanded( - child: Text( - value, - style: context.textTheme.labelMedium?.copyWith(), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), + child: Text(value, style: context.textTheme.labelMedium, maxLines: 3, overflow: TextOverflow.ellipsis), ), ], ), @@ -317,8 +665,7 @@ class FileDetailDialog extends ConsumerWidget { Future _getAssetDetails(WidgetRef ref, String localAssetId) async { try { - final repository = ref.read(localAssetRepository); - return await repository.getById(localAssetId); + return await ref.read(localAssetRepository).getById(localAssetId); } catch (e) { return null; } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 6c024600c9..ac23e6ddce 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -130,7 +130,7 @@ class SplashScreenPageState extends ConsumerState { if (isEnableBackup) { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { - unawaited(notifier.handleBackupResume(currentUser.id)); + unawaited(notifier.startForegroundBackup(currentUser.id)); } } } diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index 9d2dbe80c2..2be51fbfc9 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -1,7 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -12,7 +11,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @RoutePage() -class ShareIntentPage extends HookConsumerWidget { +class ShareIntentPage extends ConsumerWidget { const ShareIntentPage({super.key, required this.attachments}); final List attachments; @@ -21,12 +20,13 @@ class ShareIntentPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final currentEndpoint = getServerUrl() ?? '--'; final candidates = ref.watch(shareIntentUploadProvider); - final isUploaded = useState(false); - useOnAppLifecycleStateChange((previous, current) { - if (current == AppLifecycleState.resumed) { - isUploaded.value = false; - } - }); + + final isUploading = candidates.any((candidate) => candidate.status == UploadStatus.running); + final isUploaded = + candidates.isNotEmpty && + candidates.every( + (candidate) => candidate.status == UploadStatus.complete || candidate.status == UploadStatus.failed, + ); void removeAttachment(ShareIntentAttachment attachment) { ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment); @@ -37,11 +37,8 @@ class ShareIntentPage extends HookConsumerWidget { } void upload() async { - for (final attachment in candidates) { - await ref.read(shareIntentUploadProvider.notifier).upload(attachment.file); - } - - isUploaded.value = true; + final files = candidates.map((candidate) => candidate.file).toList(); + await ref.read(shareIntentUploadProvider.notifier).uploadAll(files); } bool isSelected(ShareIntentAttachment attachment) { @@ -84,7 +81,7 @@ class ShareIntentPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16), child: LargeLeadingTile( onTap: () => toggleSelection(attachment), - disabled: isUploaded.value, + disabled: isUploading || isUploaded, selected: isSelected(attachment), leading: Stack( children: [ @@ -131,8 +128,8 @@ class ShareIntentPage extends HookConsumerWidget { child: SizedBox( height: 48, child: ElevatedButton( - onPressed: isUploaded.value ? null : upload, - child: isUploaded.value ? UploadingText(candidates: candidates) : const Text('upload').tr(), + onPressed: (isUploading || isUploaded) ? null : upload, + child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : const Text('upload').tr(), ), ), ), @@ -204,14 +201,7 @@ class UploadStatusIcon extends StatelessWidget { ], ), UploadStatus.complete => Icon(Icons.check_circle_rounded, color: Colors.green, semanticLabel: 'completed'.tr()), - UploadStatus.notFound || UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()), - UploadStatus.canceled => Icon(Icons.cancel_rounded, color: Colors.red, semanticLabel: 'canceled'.tr()), - UploadStatus.waitingToRetry || UploadStatus.paused => Icon( - Icons.pause_circle_rounded, - color: context.primaryColor, - semanticLabel: 'paused'.tr(), - ), }; return statusIcon; diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index f9903b6b94..7e49348e19 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:auto_route/auto_route.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,7 +13,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; @@ -78,7 +79,7 @@ class DriftEditImagePage extends ConsumerWidget { return; } - await ref.read(uploadServiceProvider).manualBackup([localAsset]); + await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); } catch (e) { ImmichToast.show( durationInSecond: 6, diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 98ef831f9c..d69c5bced3 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -1,12 +1,17 @@ +import 'dart:async'; + 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/domain/models/asset/base_asset.model.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/backup/asset_upload_progress.provider.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'; +import 'package:immich_ui/immich_ui.dart'; class UploadActionButton extends ConsumerWidget { final ActionSource source; @@ -20,19 +25,38 @@ class UploadActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).upload(source); + final isTimeline = source == ActionSource.timeline; + List? assets; - final successMessage = 'upload_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + if (source == ActionSource.timeline) { + assets = ref.read(multiSelectProvider).selectedAssets.whereType().toList(); + if (assets.isEmpty) { + return; + } + ref.read(multiSelectProvider.notifier).reset(); + } else { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => const _UploadProgressDialog(), + ), + ); + } - if (context.mounted) { + final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); + + if (!isTimeline && context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + if (context.mounted && !result.success) { ImmichToast.show( context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + msg: 'scaffold_body_error_occurred'.t(context: context), gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, + toastType: ToastType.error, ); - - ref.read(multiSelectProvider.notifier).reset(); } } @@ -47,3 +71,42 @@ class UploadActionButton extends ConsumerWidget { ); } } + +class _UploadProgressDialog extends ConsumerWidget { + const _UploadProgressDialog(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progressMap = ref.watch(assetUploadProgressProvider); + + // Calculate overall progress from all assets + final values = progressMap.values.where((v) => v >= 0).toList(); + final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length; + final hasError = progressMap.values.any((v) => v < 0); + final percentage = (progress * 100).toInt(); + + return AlertDialog( + title: Text('uploading'.t(context: context)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasError) + const Icon(Icons.error_outline, color: Colors.red, size: 48) + else + CircularProgressIndicator(value: progress > 0 ? progress : null), + const SizedBox(height: 16), + Text(hasError ? 'Error' : '$percentage%'), + ], + ), + actions: [ + ImmichTextButton( + onPressed: () { + ref.read(manualUploadCancelTokenProvider)?.cancel(); + Navigator.of(context).pop(); + }, + labelText: 'cancel'.t(context: context), + ), + ], + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 8727f40a1a..538a9bde20 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -96,7 +96,7 @@ class NativeVideoViewer extends HookConsumerWidget { try { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; - final file = await const StorageRepository().getFileForAsset(id); + final file = await StorageRepository().getFileForAsset(id); if (!context.mounted) { return null; } diff --git a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart index ae4cfbd1c6..6361475f26 100644 --- a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; @@ -57,17 +56,13 @@ class BackupToggleButtonState extends ConsumerState with Sin @override Widget build(BuildContext context) { - final enqueueCount = ref.watch(driftBackupProvider.select((state) => state.enqueueCount)); - - final enqueueTotalCount = ref.watch(driftBackupProvider.select((state) => state.enqueueTotalCount)); - - final isCanceling = ref.watch(driftBackupProvider.select((state) => state.isCanceling)); - final uploadTasks = ref.watch(driftBackupProvider.select((state) => state.uploadItems)); final isSyncing = ref.watch(driftBackupProvider.select((state) => state.isSyncing)); - final isProcessing = uploadTasks.isNotEmpty || isSyncing; + final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress)); + + final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty; return AnimatedBuilder( animation: _animationController, @@ -115,7 +110,7 @@ class BackupToggleButtonState extends ConsumerState with Sin borderRadius: const BorderRadius.all(Radius.circular(20.5)), child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(20.5)), - onTap: () => isCanceling ? null : _onToggle(!_isEnabled), + onTap: () => _onToggle(!_isEnabled), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( @@ -154,35 +149,10 @@ class BackupToggleButtonState extends ConsumerState with Sin ), ], ), - if (enqueueCount != enqueueTotalCount) - Text( - "queue_status".t( - context: context, - args: {'count': enqueueCount.toString(), 'total': enqueueTotalCount.toString()}, - ), - style: context.textTheme.labelLarge?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ), - if (isCanceling) - Row( - children: [ - Text("canceling".t(), style: context.textTheme.labelLarge), - const SizedBox(width: 4), - SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - backgroundColor: context.colorScheme.onSurface.withValues(alpha: 0.2), - ), - ), - ], - ), ], ), ), - Switch.adaptive(value: _isEnabled, onChanged: (value) => isCanceling ? null : _onToggle(value)), + Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)), ], ), ), diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 6540c15085..bdaf67ab7e 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -8,6 +8,7 @@ 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/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -62,6 +63,10 @@ class _ThumbnailTileState extends ConsumerState { _showSelectionContainer = true; } + final uploadProgress = asset is LocalAsset + ? ref.watch(assetUploadProgressProvider.select((map) => map[asset.id])) + : null; + return Stack( children: [ Container( @@ -168,6 +173,7 @@ class _ThumbnailTileState extends ConsumerState { ), ), ), + if (uploadProgress != null) _UploadProgressOverlay(progress: uploadProgress), ], ), ), @@ -293,3 +299,46 @@ class _AssetTypeIcons extends StatelessWidget { ); } } + +class _UploadProgressOverlay extends StatelessWidget { + final double progress; + + const _UploadProgressOverlay({required this.progress}); + + @override + Widget build(BuildContext context) { + final isError = progress < 0; + final percentage = isError ? 0 : (progress * 100).toInt(); + + return Positioned.fill( + child: Container( + color: isError ? Colors.red.withValues(alpha: 0.6) : Colors.black54, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isError) + const Icon(Icons.error_outline, color: Colors.white, size: 36) + else + SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + value: progress, + strokeWidth: 3, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + const SizedBox(height: 4), + Text( + isError ? 'Error' : '$percentage%', + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 20ae8d20a3..604f1c8d0d 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -181,7 +181,7 @@ class AppLifeCycleNotifier extends StateNotifier { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { await _safeRun( - _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id), + _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id), "handleBackupResume", ); } @@ -238,6 +238,8 @@ class AppLifeCycleNotifier extends StateNotifier { if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) { _ref.read(backupProvider.notifier).cancelBackup(); } + } else { + await _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); } _ref.read(websocketProvider.notifier).disconnect(); diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index 881fdc359f..66a8deb466 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -1,37 +1,28 @@ import 'dart:io'; -import 'package:background_downloader/background_downloader.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/share_intent_service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart'; +import 'package:path/path.dart' as p; final shareIntentUploadProvider = StateNotifierProvider>( ((ref) => ShareIntentUploadStateNotifier( ref.watch(appRouterProvider), - ref.watch(uploadServiceProvider), - ref.watch(shareIntentServiceProvider), + ref.read(foregroundUploadServiceProvider), + ref.read(shareIntentServiceProvider), )), ); class ShareIntentUploadStateNotifier extends StateNotifier> { final AppRouter router; - final UploadService _uploadService; + final ForegroundUploadService _foregroundUploadService; final ShareIntentService _shareIntentService; final Logger _logger = Logger('ShareIntentUploadStateNotifier'); - ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) { - _uploadService.taskStatusStream.listen(_updateUploadStatus); - _uploadService.taskProgressStream.listen(_taskProgressCallback); - } + ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]); void init() { _shareIntentService.onSharedMedia = onSharedMedia; @@ -67,97 +58,44 @@ class ShareIntentUploadStateNotifier extends StateNotifier uploadAll(List files) async { + for (final file in files) { + final fileId = p.hash(file.path).toString(); + _updateStatus(fileId, UploadStatus.running); } - final taskId = task.task.taskId; - final uploadStatus = switch (task.status) { - TaskStatus.complete => UploadStatus.complete, - TaskStatus.failed => UploadStatus.failed, - TaskStatus.canceled => UploadStatus.canceled, - TaskStatus.enqueued => UploadStatus.enqueued, - TaskStatus.running => UploadStatus.running, - TaskStatus.paused => UploadStatus.paused, - TaskStatus.notFound => UploadStatus.notFound, - TaskStatus.waitingToRetry => UploadStatus.waitingToRetry, - }; - - state = [ - for (final attachment in state) - if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment, - ]; - - if (task.status == TaskStatus.failed) { - String? error; - final exception = task.exception; - if (exception != null && exception is TaskHttpException) { - final message = tryJsonDecode(exception.description)?['message'] as String?; - if (message != null) { - final responseCode = exception.httpResponseCode; - error = "${exception.exceptionType}, response code $responseCode: $message"; - } - } - error ??= task.exception?.toString(); - - _logger.warning("Upload failed for asset: ${task.task.filename}, error: $error"); - } - } - - void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is canceled or completed - if (update.progress == downloadFailed || update.progress == downloadCompleted) { - return; - } - - final taskId = update.task.taskId; - state = [ - for (final attachment in state) - if (attachment.id == taskId.toInt()) attachment.copyWith(uploadProgress: update.progress) else attachment, - ]; - } - - Future upload(File file) async { - final task = await _buildUploadTask(hash(file.path).toString(), file); - - await _uploadService.enqueueTasks([task]); - } - - Future _buildUploadTask(String id, File file, {Map? fields}) async { - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final url = Uri.parse('$serverEndpoint/assets').toString(); - final headers = ApiService.getRequestHeaders(); - final deviceId = Store.get(StoreKey.deviceId); - - final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); - final stats = await file.stat(); - final fileCreatedAt = stats.changed; - final fileModifiedAt = stats.modified; - - final fieldsMap = { - 'filename': filename, - 'deviceAssetId': id, - 'deviceId': deviceId, - 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), - 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), - 'isFavorite': 'false', - 'duration': '0', - if (fields != null) ...fields, - }; - - return UploadTask( - taskId: id, - httpRequestMethod: 'POST', - url: url, - headers: headers, - filename: filename, - fields: fieldsMap, - baseDirectory: baseDirectory, - directory: directory, - fileField: 'assetData', - group: kManualUploadGroup, - updates: Updates.statusAndProgress, + await _foregroundUploadService.uploadShareIntent( + files, + onProgress: (fileId, bytes, totalBytes) { + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + _updateProgress(fileId, progress); + }, + onSuccess: (fileId) { + _updateStatus(fileId, UploadStatus.complete, progress: 1.0); + }, + onError: (fileId, errorMessage) { + _logger.warning("Upload failed for file: $fileId, error: $errorMessage"); + _updateStatus(fileId, UploadStatus.failed); + }, ); } + + void _updateStatus(String fileId, UploadStatus status, {double? progress}) { + final id = int.parse(fileId); + state = [ + for (final attachment in state) + if (attachment.id == id) + attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress) + else + attachment, + ]; + } + + void _updateProgress(String fileId, double progress) { + final id = int.parse(fileId); + state = [ + for (final attachment in state) + if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment, + ]; + } } diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 91f245afc4..49dc10240b 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -11,8 +11,9 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/hash.dart'; @@ -34,6 +35,7 @@ class AuthNotifier extends StateNotifier { final AuthService _authService; final ApiService _apiService; final UserService _userService; + final SecureStorageService _secureStorageService; final WidgetService _widgetService; final Ref _ref; @@ -45,6 +47,7 @@ class AuthNotifier extends StateNotifier { this._authService, this._apiService, this._userService, + this._secureStorageService, this._widgetService, this._ref, @@ -87,7 +90,8 @@ class AuthNotifier extends StateNotifier { await _widgetService.clearCredentials(); await _authService.logout(); - await _ref.read(uploadServiceProvider).cancelBackup(); + await _ref.read(backgroundUploadServiceProvider).cancel(); + _ref.read(foregroundUploadServiceProvider).cancel(); } finally { await _cleanUp(); } diff --git a/mobile/lib/providers/backup/asset_upload_progress.provider.dart b/mobile/lib/providers/backup/asset_upload_progress.provider.dart new file mode 100644 index 0000000000..e8aba430da --- /dev/null +++ b/mobile/lib/providers/backup/asset_upload_progress.provider.dart @@ -0,0 +1,33 @@ +import 'package:cancellation_token_http/http.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Tracks per-asset upload progress. +/// Key: local asset ID, Value: upload progress 0.0 to 1.0, or -1.0 for error +class AssetUploadProgressNotifier extends Notifier> { + static const double errorValue = -1.0; + + @override + Map build() => {}; + + void setProgress(String localAssetId, double progress) { + state = {...state, localAssetId: progress}; + } + + void setError(String localAssetId) { + state = {...state, localAssetId: errorValue}; + } + + void remove(String localAssetId) { + state = Map.from(state)..remove(localAssetId); + } + + void clear() { + state = {}; + } +} + +final assetUploadProgressProvider = NotifierProvider>( + AssetUploadProgressNotifier.new, +); + +final manualUploadCancelTokenProvider = StateProvider((ref) => null); diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index ec427613f1..e2d548595c 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,19 +1,18 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:async'; -import 'package:background_downloader/background_downloader.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; + import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/string_extensions.dart'; -import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/utils/upload_speed_calculator.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/upload.service.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; class EnqueueStatus { final int enqueueCount; @@ -106,26 +105,24 @@ class DriftBackupState { final int remainderCount; final int processingCount; - final int enqueueCount; - final int enqueueTotalCount; - final bool isSyncing; - final bool isCanceling; final BackupError error; final Map uploadItems; + final CancellationToken? cancelToken; + + final Map iCloudDownloadProgress; const DriftBackupState({ required this.totalCount, required this.backupCount, required this.remainderCount, required this.processingCount, - required this.enqueueCount, - required this.enqueueTotalCount, - required this.isCanceling, required this.isSyncing, - required this.uploadItems, this.error = BackupError.none, + required this.uploadItems, + this.cancelToken, + this.iCloudDownloadProgress = const {}, }); DriftBackupState copyWith({ @@ -133,30 +130,28 @@ class DriftBackupState { int? backupCount, int? remainderCount, int? processingCount, - int? enqueueCount, - int? enqueueTotalCount, - bool? isCanceling, bool? isSyncing, - Map? uploadItems, BackupError? error, + Map? uploadItems, + CancellationToken? cancelToken, + Map? iCloudDownloadProgress, }) { return DriftBackupState( totalCount: totalCount ?? this.totalCount, backupCount: backupCount ?? this.backupCount, remainderCount: remainderCount ?? this.remainderCount, processingCount: processingCount ?? this.processingCount, - enqueueCount: enqueueCount ?? this.enqueueCount, - enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount, - isCanceling: isCanceling ?? this.isCanceling, isSyncing: isSyncing ?? this.isSyncing, - uploadItems: uploadItems ?? this.uploadItems, error: error ?? this.error, + uploadItems: uploadItems ?? this.uploadItems, + cancelToken: cancelToken ?? this.cancelToken, + iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, ); } @override String toString() { - return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, isSyncing: $isSyncing, uploadItems: $uploadItems, error: $error)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)'; } @override @@ -168,12 +163,11 @@ class DriftBackupState { other.backupCount == backupCount && other.remainderCount == remainderCount && other.processingCount == processingCount && - other.enqueueCount == enqueueCount && - other.enqueueTotalCount == enqueueTotalCount && - other.isCanceling == isCanceling && other.isSyncing == isSyncing && + other.error == error && + mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && mapEquals(other.uploadItems, uploadItems) && - other.error == error; + other.cancelToken == cancelToken; } @override @@ -182,44 +176,40 @@ class DriftBackupState { backupCount.hashCode ^ remainderCount.hashCode ^ processingCount.hashCode ^ - enqueueCount.hashCode ^ - enqueueTotalCount.hashCode ^ - isCanceling.hashCode ^ isSyncing.hashCode ^ + error.hashCode ^ uploadItems.hashCode ^ - error.hashCode; + cancelToken.hashCode ^ + iCloudDownloadProgress.hashCode; } } final driftBackupProvider = StateNotifierProvider((ref) { - return DriftBackupNotifier(ref.watch(uploadServiceProvider)); + return DriftBackupNotifier( + ref.watch(foregroundUploadServiceProvider), + ref.watch(backgroundUploadServiceProvider), + UploadSpeedManager(), + ); }); class DriftBackupNotifier extends StateNotifier { - DriftBackupNotifier(this._uploadService) + DriftBackupNotifier(this._foregroundUploadService, this._backgroundUploadService, this._uploadSpeedManager) : super( const DriftBackupState( totalCount: 0, backupCount: 0, remainderCount: 0, processingCount: 0, - enqueueCount: 0, - enqueueTotalCount: 0, - isCanceling: false, isSyncing: false, uploadItems: {}, error: BackupError.none, ), - ) { - { - _statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate); - _progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate); - } - } + ); + + final ForegroundUploadService _foregroundUploadService; + final BackgroundUploadService _backgroundUploadService; + final UploadSpeedManager _uploadSpeedManager; - final UploadService _uploadService; - StreamSubscription? _statusSubscription; - StreamSubscription? _progressSubscription; final _logger = Logger("DriftBackupNotifier"); /// Remove upload item from state @@ -235,120 +225,12 @@ class DriftBackupNotifier extends StateNotifier { } } - void _handleTaskStatusUpdate(TaskStatusUpdate update) { - if (!mounted) { - _logger.warning("Skip _handleTaskStatusUpdate: notifier disposed"); - return; - } - final taskId = update.task.taskId; - - switch (update.status) { - case TaskStatus.complete: - if (update.task.group == kBackupGroup) { - if (update.responseStatusCode == 201) { - state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); - } - } - - // Remove the completed task from the upload items - if (state.uploadItems.containsKey(taskId)) { - Future.delayed(const Duration(milliseconds: 1000), () { - _removeUploadItem(taskId); - }); - } - - case TaskStatus.failed: - // Ignore retry errors to avoid confusing users - if (update.exception?.description == 'Delayed or retried enqueue failed') { - _removeUploadItem(taskId); - return; - } - - final currentItem = state.uploadItems[taskId]; - if (currentItem == null) { - return; - } - - String? error; - final exception = update.exception; - if (exception != null && exception is TaskHttpException) { - final message = tryJsonDecode(exception.description)?['message'] as String?; - if (message != null) { - final responseCode = exception.httpResponseCode; - error = "${exception.exceptionType}, response code $responseCode: $message"; - } - } - error ??= update.exception?.toString(); - - state = state.copyWith( - uploadItems: { - ...state.uploadItems, - taskId: currentItem.copyWith(isFailed: true, error: error), - }, - ); - _logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}"); - break; - - case TaskStatus.canceled: - _removeUploadItem(update.task.taskId); - break; - - default: - break; - } - } - - void _handleTaskProgressUpdate(TaskProgressUpdate update) { - if (!mounted) { - _logger.warning("Skip _handleTaskProgressUpdate: notifier disposed"); - return; - } - final taskId = update.task.taskId; - final filename = update.task.displayName; - final progress = update.progress; - final currentItem = state.uploadItems[taskId]; - if (currentItem != null) { - if (progress == kUploadStatusCanceled) { - _removeUploadItem(update.task.taskId); - return; - } - - state = state.copyWith( - uploadItems: { - ...state.uploadItems, - taskId: update.hasExpectedFileSize - ? currentItem.copyWith( - progress: progress, - fileSize: update.expectedFileSize, - networkSpeedAsString: update.networkSpeedAsString, - ) - : currentItem.copyWith(progress: progress), - }, - ); - - return; - } - - state = state.copyWith( - uploadItems: { - ...state.uploadItems, - taskId: DriftUploadStatus( - taskId: taskId, - filename: filename, - progress: progress, - fileSize: update.expectedFileSize, - networkSpeedAsString: update.networkSpeedAsString, - ), - }, - ); - } - Future getBackupStatus(String userId) async { if (!mounted) { _logger.warning("Skip getBackupStatus (pre-call): notifier disposed"); return; } - final counts = await _uploadService.getBackupCounts(userId); + final counts = await _foregroundUploadService.getBackupCounts(userId); if (!mounted) { _logger.warning("Skip getBackupStatus (post-call): notifier disposed"); return; @@ -374,47 +256,126 @@ class DriftBackupNotifier extends StateNotifier { state = state.copyWith(isSyncing: isSyncing); } - Future startBackup(String userId) { + Future startForegroundBackup(String userId) async { state = state.copyWith(error: BackupError.none); - return _uploadService.startBackup(userId, _updateEnqueueCount); + + final cancelToken = CancellationToken(); + state = state.copyWith(cancelToken: cancelToken); + + return _foregroundUploadService.uploadCandidates( + userId, + cancelToken, + callbacks: UploadCallbacks( + onProgress: _handleForegroundBackupProgress, + onSuccess: _handleForegroundBackupSuccess, + onError: _handleForegroundBackupError, + onICloudProgress: _handleICloudProgress, + ), + ); } - void _updateEnqueueCount(EnqueueStatus status) { - state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount); + Future stopForegroundBackup() async { + state.cancelToken?.cancel(); + _uploadSpeedManager.clear(); + state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); } - Future cancel() async { - if (!mounted) { - _logger.warning("Skip cancel (pre-call): notifier disposed"); - return; + void _handleICloudProgress(String localAssetId, double progress) { + state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress}); + + if (progress >= 1.0) { + Future.delayed(const Duration(milliseconds: 250), () { + final updatedProgress = Map.from(state.iCloudDownloadProgress); + updatedProgress.remove(localAssetId); + state = state.copyWith(iCloudDownloadProgress: updatedProgress); + }); } - dPrint(() => "Canceling backup tasks..."); - state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none); + } - final activeTaskCount = await _uploadService.cancelBackup(); - if (!mounted) { - _logger.warning("Skip cancel (post-call): notifier disposed"); + void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) { + if (state.cancelToken == null) { return; } - if (activeTaskCount > 0) { - dPrint(() => "$activeTaskCount tasks left, continuing to cancel..."); - await cancel(); + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + final networkSpeedAsString = _uploadSpeedManager.updateProgress(localAssetId, bytes, totalBytes); + final currentItem = state.uploadItems[localAssetId]; + if (currentItem != null) { + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + localAssetId: currentItem.copyWith( + filename: filename, + progress: progress, + fileSize: totalBytes, + networkSpeedAsString: networkSpeedAsString, + ), + }, + ); } else { - dPrint(() => "All tasks canceled successfully."); - // Clear all upload items when cancellation is complete - state = state.copyWith(isCanceling: false, uploadItems: {}); + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + localAssetId: DriftUploadStatus( + taskId: localAssetId, + filename: filename, + progress: progress, + fileSize: totalBytes, + networkSpeedAsString: networkSpeedAsString, + ), + }, + ); } } - Future handleBackupResume(String userId) async { + void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) { + state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); + _uploadSpeedManager.removeTask(localAssetId); + + Future.delayed(const Duration(milliseconds: 1000), () { + _removeUploadItem(localAssetId); + }); + } + + void _handleForegroundBackupError(String localAssetId, String errorMessage) { + _logger.severe("Upload failed for $localAssetId: $errorMessage"); + + final currentItem = state.uploadItems[localAssetId]; + if (currentItem != null) { + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + localAssetId: currentItem.copyWith(isFailed: true, error: errorMessage), + }, + ); + } else { + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + localAssetId: DriftUploadStatus( + taskId: localAssetId, + filename: 'Unknown', + progress: 0, + fileSize: 0, + networkSpeedAsString: '', + isFailed: true, + error: errorMessage, + ), + }, + ); + } + + _uploadSpeedManager.removeTask(localAssetId); + } + + Future startBackupWithURLSession(String userId) async { if (!mounted) { _logger.warning("Skip handleBackupResume (pre-call): notifier disposed"); return; } _logger.info("Resuming backup tasks..."); state = state.copyWith(error: BackupError.none); - final tasks = await _uploadService.getActiveTasks(kBackupGroup); + final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup); if (!mounted) { _logger.warning("Skip handleBackupResume (post-call): notifier disposed"); return; @@ -422,20 +383,12 @@ class DriftBackupNotifier extends StateNotifier { _logger.info("Found ${tasks.length} tasks"); if (tasks.isEmpty) { - // Start a new backup queue - _logger.info("Start a new backup queue"); - return startBackup(userId); + _logger.info("Start backup with URLSession"); + return _backgroundUploadService.uploadBackupCandidates(userId); } _logger.info("Tasks to resume: ${tasks.length}"); - return _uploadService.resumeBackup(); - } - - @override - void dispose() { - _statusSubscription?.cancel(); - _progressSubscription?.cancel(); - super.dispose(); + return _backgroundUploadService.resume(); } } @@ -445,7 +398,7 @@ final driftBackupCandidateProvider = FutureProvider.autoDispose return []; } - return ref.read(backupRepositoryProvider).getCandidates(user.id, onlyHashed: false); + return ref.read(foregroundUploadServiceProvider).getBackupCandidates(user.id, onlyHashed: false); }); final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family, String>(( diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index d4d850d8c1..48ce88799a 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -13,10 +14,11 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.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:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -40,7 +42,7 @@ class ActionResult { class ActionNotifier extends Notifier { final Logger _logger = Logger('ActionNotifier'); late ActionService _service; - late UploadService _uploadService; + late ForegroundUploadService _foregroundUploadService; late DownloadService _downloadService; late AssetService _assetService; @@ -48,7 +50,7 @@ class ActionNotifier extends Notifier { @override void build() { - _uploadService = ref.watch(uploadServiceProvider); + _foregroundUploadService = ref.watch(foregroundUploadServiceProvider); _service = ref.watch(actionServiceProvider); _assetService = ref.watch(assetServiceProvider); _downloadService = ref.watch(downloadServiceProvider); @@ -411,14 +413,44 @@ class ActionNotifier extends Notifier { } } - Future upload(ActionSource source) async { - final assets = _getAssets(source).whereType().toList(); + Future upload(ActionSource source, {List? assets}) async { + final assetsToUpload = assets ?? _getAssets(source).whereType().toList(); + + final progressNotifier = ref.read(assetUploadProgressProvider.notifier); + final cancelToken = CancellationToken(); + ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; + + // Initialize progress for all assets + for (final asset in assetsToUpload) { + progressNotifier.setProgress(asset.id, 0.0); + } + try { - await _uploadService.manualBackup(assets); - return ActionResult(count: assets.length, success: true); + await _foregroundUploadService.uploadManual( + assetsToUpload, + cancelToken, + callbacks: UploadCallbacks( + onProgress: (localAssetId, filename, bytes, totalBytes) { + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + progressNotifier.setProgress(localAssetId, progress); + }, + onSuccess: (localAssetId, remoteAssetId) { + progressNotifier.remove(localAssetId); + }, + onError: (localAssetId, errorMessage) { + progressNotifier.setError(localAssetId); + }, + ), + ); + return ActionResult(count: assetsToUpload.length, success: true); } catch (error, stack) { _logger.severe('Failed manually upload assets', error, stack); - return ActionResult(count: assets.length, success: false, error: error.toString()); + return ActionResult(count: assetsToUpload.length, success: false, error: error.toString()); + } finally { + ref.read(manualUploadCancelTokenProvider.notifier).state = null; + Future.delayed(const Duration(seconds: 2), () { + progressNotifier.clear(); + }); } } } diff --git a/mobile/lib/providers/infrastructure/storage.provider.dart b/mobile/lib/providers/infrastructure/storage.provider.dart index ccca964027..82d1209c97 100644 --- a/mobile/lib/providers/infrastructure/storage.provider.dart +++ b/mobile/lib/providers/infrastructure/storage.provider.dart @@ -1,4 +1,4 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -final storageRepositoryProvider = Provider((ref) => const StorageRepository()); +final storageRepositoryProvider = Provider((ref) => StorageRepository()); diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 38f2c22cf2..aff84683c3 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -20,6 +21,7 @@ class UploadTaskWithFile { final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { + final Logger logger = Logger('UploadRepository'); void Function(TaskStatusUpdate)? onUploadStatus; void Function(TaskProgressUpdate)? onTaskProgress; @@ -92,52 +94,114 @@ class UploadRepository { ); } - Future backupWithDartClient(Iterable tasks, CancellationToken cancelToken) async { - final httpClient = Client(); + Future uploadFile({ + required File file, + required String originalFileName, + required Map headers, + required Map fields, + required Client httpClient, + required CancellationToken cancelToken, + required void Function(int bytes, int totalBytes) onProgress, + required String logContext, + }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - Logger logger = Logger('UploadRepository'); - for (final candidate in tasks) { - if (cancelToken.isCancelled) { - logger.warning("Backup was cancelled by the user"); - break; + try { + final fileStream = file.openRead(); + final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); + + final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress); + + baseRequest.headers.addAll(headers); + baseRequest.fields.addAll(fields); + baseRequest.files.add(assetRawUploadData); + + final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final responseBodyString = await response.stream.bytesToString(); + + if (![200, 201].contains(response.statusCode)) { + String? errorMessage; + + if (response.statusCode == 413) { + errorMessage = 'Error(413) File is too large to upload'; + return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); + } + + try { + final error = jsonDecode(responseBodyString); + errorMessage = error['message'] ?? error['error']; + } catch (_) { + errorMessage = responseBodyString.isNotEmpty + ? responseBodyString + : 'Upload failed with status ${response.statusCode}'; + } + + return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); } try { - final fileStream = candidate.file.openRead(); - final assetRawUploadData = MultipartFile( - "assetData", - fileStream, - candidate.file.lengthSync(), - filename: candidate.task.filename, - ); - - final baseRequest = MultipartRequest('POST', Uri.parse('$savedEndpoint/assets')); - - baseRequest.headers.addAll(candidate.task.headers); - baseRequest.fields.addAll(candidate.task.fields); - baseRequest.files.add(assetRawUploadData); - - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); - - final responseBody = jsonDecode(await response.stream.bytesToString()); - - if (![200, 201].contains(response.statusCode)) { - final error = responseBody; - - logger.warning( - "Error(${error['statusCode']}) uploading ${candidate.task.filename} | Created on ${candidate.task.fields["fileCreatedAt"]} | ${error['error']}", - ); - - continue; - } - } on CancelledException { - logger.warning("Backup was cancelled by the user"); - break; - } catch (error, stackTrace) { - logger.warning("Error backup asset: ${error.toString()}: $stackTrace"); - continue; + final responseBody = jsonDecode(responseBodyString); + return UploadResult.success(remoteAssetId: responseBody['id'] as String); + } catch (e) { + return UploadResult.error(errorMessage: 'Failed to parse server response'); } + } on CancelledException { + logger.warning("Upload $logContext was cancelled"); + return UploadResult.cancelled(); + } catch (error, stackTrace) { + logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace"); + return UploadResult.error(errorMessage: error.toString()); } } } + +class UploadResult { + final bool isSuccess; + final bool isCancelled; + final String? remoteAssetId; + final String? errorMessage; + final int? statusCode; + + const UploadResult({ + required this.isSuccess, + required this.isCancelled, + this.remoteAssetId, + this.errorMessage, + this.statusCode, + }); + + factory UploadResult.success({required String remoteAssetId}) { + return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId); + } + + factory UploadResult.error({String? errorMessage, int? statusCode}) { + return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode); + } + + factory UploadResult.cancelled() { + return const UploadResult(isSuccess: false, isCancelled: true); + } +} + +class _CustomMultipartRequest extends MultipartRequest { + _CustomMultipartRequest(super.method, super.url, {required this.onProgress}); + + final void Function(int bytes, int totalBytes) onProgress; + + @override + ByteStream finalize() { + final byteStream = super.finalize(); + final total = contentLength; + var bytes = 0; + + final t = StreamTransformer.fromHandlers( + handleData: (List data, EventSink> sink) { + bytes += data.length; + onProgress.call(bytes, total); + sink.add(data); + }, + ); + final stream = byteStream.transform(t); + return ByteStream(stream); + } +} diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/background_upload.service.dart similarity index 75% rename from mobile/lib/services/upload.service.dart rename to mobile/lib/services/background_upload.service.dart index f4ee73ad41..fe1e4ac13b 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -15,12 +14,9 @@ import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -29,43 +25,98 @@ import 'package:immich_mobile/utils/debug_print.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; -final uploadServiceProvider = Provider((ref) { - final service = UploadService( +final backgroundUploadServiceProvider = Provider((ref) { + final service = BackgroundUploadService( ref.watch(uploadRepositoryProvider), - ref.watch(backupRepositoryProvider), ref.watch(storageRepositoryProvider), ref.watch(localAssetRepository), + ref.watch(backupRepositoryProvider), ref.watch(appSettingsServiceProvider), ref.watch(assetMediaRepositoryProvider), - ref.watch(serverInfoProvider), ); ref.onDispose(service.dispose); return service; }); -class UploadService { - UploadService( +/// Metadata for upload tasks to track live photo handling +class UploadTaskMetadata { + final String localAssetId; + final bool isLivePhotos; + final String livePhotoVideoId; + + const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId}); + + UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) { + return UploadTaskMetadata( + localAssetId: localAssetId ?? this.localAssetId, + isLivePhotos: isLivePhotos ?? this.isLivePhotos, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + ); + } + + Map toMap() { + return { + 'localAssetId': localAssetId, + 'isLivePhotos': isLivePhotos, + 'livePhotoVideoId': livePhotoVideoId, + }; + } + + factory UploadTaskMetadata.fromMap(Map map) { + return UploadTaskMetadata( + localAssetId: map['localAssetId'] as String, + isLivePhotos: map['isLivePhotos'] as bool, + livePhotoVideoId: map['livePhotoVideoId'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory UploadTaskMetadata.fromJson(String source) => + UploadTaskMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)'; + + @override + bool operator ==(covariant UploadTaskMetadata other) { + if (identical(this, other)) return true; + + return other.localAssetId == localAssetId && + other.isLivePhotos == isLivePhotos && + other.livePhotoVideoId == livePhotoVideoId; + } + + @override + int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode; +} + +/// Service for handling background uploads using iOS URLSession (background_downloader) +/// +/// This service handles asynchronous background uploads that can continue +/// even when the app is suspended. Primarily used for iOS background backup. +class BackgroundUploadService { + BackgroundUploadService( this._uploadRepository, - this._backupRepository, this._storageRepository, this._localAssetRepository, + this._backupRepository, this._appSettingsService, this._assetMediaRepository, - this._serverInfo, ) { _uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onTaskProgress = _onTaskProgressCallback; } final UploadRepository _uploadRepository; - final DriftBackupRepository _backupRepository; final StorageRepository _storageRepository; final DriftLocalAssetRepository _localAssetRepository; + final DriftBackupRepository _backupRepository; final AppSettingsService _appSettingsService; final AssetMediaRepository _assetMediaRepository; - final ServerInfo _serverInfo; - final Logger _logger = Logger('UploadService'); + final Logger _logger = Logger('BackgroundUploadService'); final StreamController _taskStatusController = StreamController.broadcast(); final StreamController _taskProgressController = StreamController.broadcast(); @@ -93,116 +144,49 @@ class UploadService { _taskProgressController.close(); } + /// Enqueue tasks to the background upload queue Future> enqueueTasks(List tasks) { return _uploadRepository.enqueueBackgroundAll(tasks); } + /// Get a list of tasks that are ENQUEUED or RUNNING Future> getActiveTasks(String group) { return _uploadRepository.getActiveTasks(group); } - Future<({int total, int remainder, int processing})> getBackupCounts(String userId) { - return _backupRepository.getAllCounts(userId); - } - - Future manualBackup(List localAssets) async { + /// Start background upload using iOS URLSession + /// + /// Finds backup candidates, builds upload tasks, and enqueues them + /// for background processing. + Future uploadBackupCandidates(String userId) async { await _storageRepository.clearCache(); + shouldAbortQueuingTasks = false; + + final candidates = await _backupRepository.getCandidates(userId); + if (candidates.isEmpty) { + return; + } + + const batchSize = 100; + final batch = candidates.take(batchSize).toList(); List tasks = []; - for (final asset in localAssets) { - final task = await getUploadTask( - asset, - group: kManualUploadGroup, - priority: 1, // High priority after upload motion photo part - ); + + for (final asset in batch) { + final task = await getUploadTask(asset); if (task != null) { tasks.add(task); } } - if (tasks.isNotEmpty) { + if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { await enqueueTasks(tasks); } } - /// Find backup candidates - /// Build the upload tasks - /// Enqueue the tasks - Future startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async { - await _storageRepository.clearCache(); - - shouldAbortQueuingTasks = false; - - final candidates = await _backupRepository.getCandidates(userId); - if (candidates.isEmpty) { - return; - } - - const batchSize = 100; - int count = 0; - for (int i = 0; i < candidates.length; i += batchSize) { - if (shouldAbortQueuingTasks) { - break; - } - - final batch = candidates.skip(i).take(batchSize).toList(); - List tasks = []; - for (final asset in batch) { - final task = await getUploadTask(asset); - if (task != null) { - tasks.add(task); - } - } - - if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { - count += tasks.length; - await enqueueTasks(tasks); - - onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length)); - } - } - } - - Future startBackupWithHttpClient(String userId, bool hasWifi, CancellationToken token) async { - await _storageRepository.clearCache(); - - shouldAbortQueuingTasks = false; - - final candidates = await _backupRepository.getCandidates(userId); - if (candidates.isEmpty) { - return; - } - - const batchSize = 100; - for (int i = 0; i < candidates.length; i += batchSize) { - if (shouldAbortQueuingTasks || token.isCancelled) { - break; - } - - final batch = candidates.skip(i).take(batchSize).toList(); - List tasks = []; - for (final asset in batch) { - final requireWifi = _shouldRequireWiFi(asset); - if (requireWifi && !hasWifi) { - _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); - continue; - } - - final task = await _getUploadTaskWithFile(asset); - if (task != null) { - tasks.add(task); - } - } - - if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { - await _uploadRepository.backupWithDartClient(tasks, token); - } - } - } - - /// Cancel all ongoing uploads and reset the upload queue + /// Cancel all ongoing background uploads and reset the upload queue /// - /// Return the number of left over tasks in the queue - Future cancelBackup() async { + /// Returns the number of tasks left in the queue + Future cancel() async { shouldAbortQueuingTasks = true; await _storageRepository.clearCache(); @@ -213,7 +197,8 @@ class UploadService { return activeTasks.length; } - Future resumeBackup() { + /// Resume background backup processing + Future resume() { return _uploadRepository.start(); } @@ -271,42 +256,6 @@ class UploadService { } } - Future _getUploadTaskWithFile(LocalAsset asset) async { - final entity = await _storageRepository.getAssetEntityForAsset(asset); - if (entity == null) { - return null; - } - - final file = await _storageRepository.getFileForAsset(asset.id); - if (file == null) { - return null; - } - - final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; - - String metadata = UploadTaskMetadata( - localAssetId: asset.id, - isLivePhotos: entity.isLivePhoto, - livePhotoVideoId: '', - ).toJson(); - - return UploadTaskWithFile( - file: file, - task: await buildUploadTask( - file, - createdAt: asset.createdAt, - modifiedAt: asset.updatedAt, - originalFileName: originalFileName, - deviceAssetId: asset.id, - metadata: metadata, - group: "group", - priority: 0, - isFavorite: asset.isFavorite, - requiresWiFi: false, - ), - ); - } - @visibleForTesting Future getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async { final entity = await _storageRepository.getAssetEntityForAsset(asset); @@ -443,8 +392,7 @@ class UploadService { 'isFavorite': isFavorite?.toString() ?? 'false', 'duration': '0', if (fields != null) ...fields, - // Include cloudId and eTag in metadata if available and server version supports it - if (CurrentPlatform.isIOS && cloudId != null && _serverInfo.serverVersion.isAtLeast(major: 2, minor: 4)) + if (CurrentPlatform.isIOS && cloudId != null) 'metadata': jsonEncode([ RemoteAssetMetadataItem( key: RemoteAssetMetadataKey.mobileApp, @@ -479,56 +427,3 @@ class UploadService { ); } } - -class UploadTaskMetadata { - final String localAssetId; - final bool isLivePhotos; - final String livePhotoVideoId; - - const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId}); - - UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) { - return UploadTaskMetadata( - localAssetId: localAssetId ?? this.localAssetId, - isLivePhotos: isLivePhotos ?? this.isLivePhotos, - livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, - ); - } - - Map toMap() { - return { - 'localAssetId': localAssetId, - 'isLivePhotos': isLivePhotos, - 'livePhotoVideoId': livePhotoVideoId, - }; - } - - factory UploadTaskMetadata.fromMap(Map map) { - return UploadTaskMetadata( - localAssetId: map['localAssetId'] as String, - isLivePhotos: map['isLivePhotos'] as bool, - livePhotoVideoId: map['livePhotoVideoId'] as String, - ); - } - - String toJson() => json.encode(toMap()); - - factory UploadTaskMetadata.fromJson(String source) => - UploadTaskMetadata.fromMap(json.decode(source) as Map); - - @override - String toString() => - 'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)'; - - @override - bool operator ==(covariant UploadTaskMetadata other) { - if (identical(this, other)) return true; - - return other.localAssetId == localAssetId && - other.isLivePhotos == isLivePhotos && - other.livePhotoVideoId == livePhotoVideoId; - } - - @override - int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode; -} diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart new file mode 100644 index 0000000000..b979096e1c --- /dev/null +++ b/mobile/lib/services/foreground_upload.service.dart @@ -0,0 +1,461 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:cancellation_token_http/http.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/extensions/network_capability_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/platform/connectivity_api.g.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; + +/// Callbacks for upload progress and status updates +class UploadCallbacks { + final void Function(String id, String filename, int bytes, int totalBytes)? onProgress; + final void Function(String localId, String remoteId)? onSuccess; + final void Function(String id, String errorMessage)? onError; + final void Function(String id, double progress)? onICloudProgress; + + const UploadCallbacks({this.onProgress, this.onSuccess, this.onError, this.onICloudProgress}); +} + +final foregroundUploadServiceProvider = Provider((ref) { + return ForegroundUploadService( + ref.watch(uploadRepositoryProvider), + ref.watch(storageRepositoryProvider), + ref.watch(backupRepositoryProvider), + ref.watch(connectivityApiProvider), + ref.watch(appSettingsServiceProvider), + ); +}); + +/// Service for handling foreground HTTP uploads +/// +/// This service handles synchronous uploads using HTTP client with +/// concurrent worker pools. Used for manual backups, auto backups +/// (foreground mode), and share intent uploads. +class ForegroundUploadService { + ForegroundUploadService( + this._uploadRepository, + this._storageRepository, + this._backupRepository, + this._connectivityApi, + this._appSettingsService, + ); + + final UploadRepository _uploadRepository; + final StorageRepository _storageRepository; + final DriftBackupRepository _backupRepository; + final ConnectivityApi _connectivityApi; + final AppSettingsService _appSettingsService; + final Logger _logger = Logger('ForegroundUploadService'); + + bool shouldAbortUpload = false; + + Future<({int total, int remainder, int processing})> getBackupCounts(String userId) { + return _backupRepository.getAllCounts(userId); + } + + Future> getBackupCandidates(String userId, {bool onlyHashed = true}) { + return _backupRepository.getCandidates(userId, onlyHashed: onlyHashed); + } + + /// Bulk upload of backup candidates from selected albums + Future uploadCandidates( + String userId, + CancellationToken cancelToken, { + UploadCallbacks callbacks = const UploadCallbacks(), + bool useSequentialUpload = false, + }) async { + final candidates = await _backupRepository.getCandidates(userId); + if (candidates.isEmpty) { + return; + } + + final networkCapabilities = await _connectivityApi.getCapabilities(); + final hasWifi = networkCapabilities.isUnmetered; + _logger.info('Network capabilities: $networkCapabilities, hasWifi/isUnmetered: $hasWifi'); + + if (useSequentialUpload) { + await _uploadSequentially(items: candidates, cancelToken: cancelToken, hasWifi: hasWifi, callbacks: callbacks); + } else { + await _executeWithWorkerPool( + items: candidates, + cancelToken: cancelToken, + shouldSkip: (asset) { + final requireWifi = _shouldRequireWiFi(asset); + return requireWifi && !hasWifi; + }, + processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + ); + } + } + + /// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues + Future _uploadSequentially({ + required List items, + required CancellationToken cancelToken, + required bool hasWifi, + required UploadCallbacks callbacks, + }) async { + final httpClient = Client(); + await _storageRepository.clearCache(); + shouldAbortUpload = false; + + try { + for (final asset in items) { + if (shouldAbortUpload || cancelToken.isCancelled) { + break; + } + + final requireWifi = _shouldRequireWiFi(asset); + if (requireWifi && !hasWifi) { + _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); + continue; + } + + await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks); + } + } finally { + httpClient.close(); + } + } + + /// Manually upload picked local assets + Future uploadManual( + List localAssets, + CancellationToken cancelToken, { + UploadCallbacks callbacks = const UploadCallbacks(), + }) async { + if (localAssets.isEmpty) { + return; + } + + await _executeWithWorkerPool( + items: localAssets, + cancelToken: cancelToken, + processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + ); + } + + /// Upload files from shared intent + Future uploadShareIntent( + List files, { + CancellationToken? cancelToken, + void Function(String fileId, int bytes, int totalBytes)? onProgress, + void Function(String fileId)? onSuccess, + void Function(String fileId, String errorMessage)? onError, + }) async { + if (files.isEmpty) { + return; + } + + final effectiveCancelToken = cancelToken ?? CancellationToken(); + + await _executeWithWorkerPool( + items: files, + cancelToken: effectiveCancelToken, + processItem: (file, httpClient) async { + final fileId = p.hash(file.path).toString(); + + final result = await _uploadSingleFile( + file, + deviceAssetId: fileId, + httpClient: httpClient, + cancelToken: effectiveCancelToken, + onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), + ); + + if (result.isSuccess) { + onSuccess?.call(fileId); + } else if (!result.isCancelled && result.errorMessage != null) { + onError?.call(fileId, result.errorMessage!); + } + }, + ); + } + + void cancel() { + shouldAbortUpload = true; + } + + /// Generic worker pool for concurrent uploads + /// + /// [items] - List of items to process + /// [cancelToken] - Token to cancel the operation + /// [processItem] - Function to process each item with an HTTP client + /// [shouldSkip] - Optional function to skip items (e.g., WiFi requirement check) + /// [concurrentWorkers] - Number of concurrent workers (default: 3) + Future _executeWithWorkerPool({ + required List items, + required CancellationToken cancelToken, + required Future Function(T item, Client httpClient) processItem, + bool Function(T item)? shouldSkip, + int concurrentWorkers = 3, + }) async { + final httpClients = List.generate(concurrentWorkers, (_) => Client()); + + await _storageRepository.clearCache(); + shouldAbortUpload = false; + + try { + int currentIndex = 0; + + Future worker(Client httpClient) async { + while (true) { + if (shouldAbortUpload || cancelToken.isCancelled) { + break; + } + + final index = currentIndex; + if (index >= items.length) { + break; + } + currentIndex++; + + final item = items[index]; + + if (shouldSkip?.call(item) ?? false) { + continue; + } + + await processItem(item, httpClient); + } + } + + final workerFutures = >[]; + for (int i = 0; i < concurrentWorkers; i++) { + workerFutures.add(worker(httpClients[i])); + } + + await Future.wait(workerFutures); + } finally { + for (final client in httpClients) { + client.close(); + } + } + } + + Future _uploadSingleAsset( + LocalAsset asset, + Client httpClient, + CancellationToken cancelToken, { + required UploadCallbacks callbacks, + }) async { + File? file; + File? livePhotoFile; + + try { + final entity = await _storageRepository.getAssetEntityForAsset(asset); + if (entity == null) { + return; + } + + final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id); + + if (!isAvailableLocally && CurrentPlatform.isIOS) { + _logger.info("Loading iCloud asset ${asset.id} - ${asset.name}"); + + // Create progress handler for iCloud download + PMProgressHandler? progressHandler; + StreamSubscription? progressSubscription; + + progressHandler = PMProgressHandler(); + progressSubscription = progressHandler.stream.listen((event) { + callbacks.onICloudProgress?.call(asset.localId!, event.progress); + }); + + try { + file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler); + if (entity.isLivePhoto) { + livePhotoFile = await _storageRepository.loadMotionFileFromCloud( + asset.id, + progressHandler: progressHandler, + ); + } + } finally { + await progressSubscription.cancel(); + } + } else { + // Get files locally + file = await _storageRepository.getFileForAsset(asset.id); + if (file == null) { + return; + } + + // For live photos, get the motion video file + if (entity.isLivePhoto) { + livePhotoFile = await _storageRepository.getMotionFileForAsset(asset); + if (livePhotoFile == null) { + _logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}"); + } + } + } + + if (file == null) { + _logger.warning("Failed to obtain file for asset ${asset.id} - ${asset.name}"); + return; + } + + final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; + final deviceId = Store.get(StoreKey.deviceId); + + final headers = ApiService.getRequestHeaders(); + final fields = { + 'deviceAssetId': asset.localId!, + 'deviceId': deviceId, + 'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(), + 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), + 'isFavorite': asset.isFavorite.toString(), + 'duration': asset.duration.toString(), + if (CurrentPlatform.isIOS && asset.cloudId != null) + 'metadata': jsonEncode([ + RemoteAssetMetadataItem( + key: RemoteAssetMetadataKey.mobileApp, + value: RemoteAssetMobileAppMetadata( + cloudId: asset.cloudId, + createdAt: asset.createdAt.toIso8601String(), + adjustmentTime: asset.adjustmentTime?.toIso8601String(), + latitude: asset.latitude?.toString(), + longitude: asset.longitude?.toString(), + ), + ), + ]), + }; + + // Upload live photo video first if available + String? livePhotoVideoId; + if (entity.isLivePhoto && livePhotoFile != null) { + final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); + + final livePhotoResult = await _uploadRepository.uploadFile( + file: livePhotoFile, + originalFileName: livePhotoTitle, + headers: headers, + fields: fields, + httpClient: httpClient, + cancelToken: cancelToken, + onProgress: (bytes, totalBytes) => + callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes), + logContext: 'livePhotoVideo[${asset.localId}]', + ); + + if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) { + livePhotoVideoId = livePhotoResult.remoteAssetId; + } + } + + if (livePhotoVideoId != null) { + fields['livePhotoVideoId'] = livePhotoVideoId; + } + + final result = await _uploadRepository.uploadFile( + file: file, + originalFileName: originalFileName, + headers: headers, + fields: fields, + httpClient: httpClient, + cancelToken: cancelToken, + onProgress: (bytes, totalBytes) => + callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes), + logContext: 'asset[${asset.localId}]', + ); + + if (result.isSuccess && result.remoteAssetId != null) { + callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!); + } else if (result.isCancelled) { + _logger.warning(() => "Backup was cancelled by the user"); + shouldAbortUpload = true; + } else if (result.errorMessage != null) { + _logger.severe( + () => + "Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}", + ); + + callbacks.onError?.call(asset.localId!, result.errorMessage!); + + if (result.errorMessage == "Quota has been exceeded!") { + shouldAbortUpload = true; + } + } + } catch (error, stackTrace) { + _logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace); + callbacks.onError?.call(asset.localId!, error.toString()); + } finally { + if (Platform.isIOS) { + try { + await file?.delete(); + await livePhotoFile?.delete(); + } catch (error, stackTrace) { + _logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace); + } + } + } + } + + Future _uploadSingleFile( + File file, { + required String deviceAssetId, + required Client httpClient, + required CancellationToken cancelToken, + void Function(int bytes, int totalBytes)? onProgress, + }) async { + try { + final stats = await file.stat(); + final fileCreatedAt = stats.changed; + final fileModifiedAt = stats.modified; + final filename = p.basename(file.path); + + final headers = ApiService.getRequestHeaders(); + final deviceId = Store.get(StoreKey.deviceId); + + final fields = { + 'deviceAssetId': deviceAssetId, + 'deviceId': deviceId, + 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), + 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), + 'isFavorite': 'false', + 'duration': '0', + }; + + return await _uploadRepository.uploadFile( + file: file, + originalFileName: filename, + headers: headers, + fields: fields, + httpClient: httpClient, + cancelToken: cancelToken, + onProgress: onProgress ?? (_, __) {}, + logContext: 'shareIntent[$deviceAssetId]', + ); + } catch (e) { + return UploadResult.error(errorMessage: e.toString()); + } + } + + bool _shouldRequireWiFi(LocalAsset asset) { + bool requiresWiFi = true; + + if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) { + requiresWiFi = false; + } else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) { + requiresWiFi = false; + } + + return requiresWiFi; + } +} diff --git a/mobile/lib/utils/upload_speed_calculator.dart b/mobile/lib/utils/upload_speed_calculator.dart new file mode 100644 index 0000000000..a2153e6e3d --- /dev/null +++ b/mobile/lib/utils/upload_speed_calculator.dart @@ -0,0 +1,182 @@ +/// A class to calculate upload speed based on progress updates. +/// +/// Tracks bytes transferred over time and calculates average speed +/// using a sliding window approach to smooth out fluctuations. +class UploadSpeedCalculator { + /// Creates an UploadSpeedCalculator with the given window size. + /// + /// [windowSize] determines how many recent samples to use for + /// calculating the average speed. Default is 5 samples. + UploadSpeedCalculator({this.windowSize = 5}); + + /// The number of samples to keep in the sliding window. + final int windowSize; + + /// List of recent speed samples (bytes per second). + final List _speedSamples = []; + + /// The timestamp of the last progress update. + DateTime? _lastUpdateTime; + + /// The bytes transferred at the last progress update. + int _lastBytes = 0; + + /// The total file size being uploaded. + int _totalBytes = 0; + + /// Resets the calculator for a new upload. + void reset() { + _speedSamples.clear(); + _lastUpdateTime = null; + _lastBytes = 0; + _totalBytes = 0; + } + + /// Updates the calculator with the current progress. + /// + /// [currentBytes] is the number of bytes transferred so far. + /// [totalBytes] is the total size of the file being uploaded. + /// + /// Returns the calculated speed in MB/s, or -1 if not enough data. + double update(int currentBytes, int totalBytes) { + final now = DateTime.now(); + _totalBytes = totalBytes; + + if (_lastUpdateTime == null) { + _lastUpdateTime = now; + _lastBytes = currentBytes; + return -1; + } + + final elapsed = now.difference(_lastUpdateTime!); + + // Only calculate if at least 100ms has passed to avoid division by very small numbers + if (elapsed.inMilliseconds < 100) { + return _currentSpeed; + } + + final bytesTransferred = currentBytes - _lastBytes; + final elapsedSeconds = elapsed.inMilliseconds / 1000.0; + + // Calculate bytes per second, then convert to MB/s + final bytesPerSecond = bytesTransferred / elapsedSeconds; + final mbPerSecond = bytesPerSecond / (1024 * 1024); + + // Add to sliding window + _speedSamples.add(mbPerSecond); + if (_speedSamples.length > windowSize) { + _speedSamples.removeAt(0); + } + + _lastUpdateTime = now; + _lastBytes = currentBytes; + + return _currentSpeed; + } + + /// Returns the current calculated speed in MB/s. + /// + /// Returns -1 if no valid speed has been calculated yet. + double get _currentSpeed { + if (_speedSamples.isEmpty) { + return -1; + } + // Calculate average of all samples in the window + final sum = _speedSamples.fold(0.0, (prev, speed) => prev + speed); + return sum / _speedSamples.length; + } + + /// Returns the current speed in MB/s, or -1 if not available. + double get speed => _currentSpeed; + + /// Returns a human-readable string representation of the current speed. + /// + /// Returns '-- MB/s' if N/A, otherwise in MB/s or kB/s format. + String get speedAsString { + final s = _currentSpeed; + return switch (s) { + <= 0 => '-- MB/s', + >= 1 => '${s.round()} MB/s', + _ => '${(s * 1000).round()} kB/s', + }; + } + + /// Returns the estimated time remaining as a Duration. + /// + /// Returns Duration with negative seconds if not calculable. + Duration get timeRemaining { + final s = _currentSpeed; + if (s <= 0 || _totalBytes <= 0 || _lastBytes >= _totalBytes) { + return const Duration(seconds: -1); + } + + final remainingBytes = _totalBytes - _lastBytes; + final bytesPerSecond = s * 1024 * 1024; + final secondsRemaining = remainingBytes / bytesPerSecond; + + return Duration(seconds: secondsRemaining.round()); + } + + /// Returns a human-readable string representation of time remaining. + /// + /// Returns '--:--' if N/A, otherwise HH:MM:SS or MM:SS format. + String get timeRemainingAsString { + final remaining = timeRemaining; + return switch (remaining.inSeconds) { + <= 0 => '--:--', + < 3600 => + '${remaining.inMinutes.toString().padLeft(2, "0")}' + ':${remaining.inSeconds.remainder(60).toString().padLeft(2, "0")}', + _ => + '${remaining.inHours}' + ':${remaining.inMinutes.remainder(60).toString().padLeft(2, "0")}' + ':${remaining.inSeconds.remainder(60).toString().padLeft(2, "0")}', + }; + } +} + +/// Manager for tracking upload speeds for multiple concurrent uploads. +/// +/// Each upload is identified by a unique task ID. +class UploadSpeedManager { + /// Map of task IDs to their speed calculators. + final Map _calculators = {}; + + /// Gets or creates a speed calculator for the given task ID. + UploadSpeedCalculator getCalculator(String taskId) { + return _calculators.putIfAbsent(taskId, () => UploadSpeedCalculator()); + } + + /// Updates progress for a specific task and returns the speed string. + /// + /// [taskId] is the unique identifier for the upload task. + /// [currentBytes] is the number of bytes transferred so far. + /// [totalBytes] is the total size of the file being uploaded. + /// + /// Returns the human-readable speed string. + String updateProgress(String taskId, int currentBytes, int totalBytes) { + final calculator = getCalculator(taskId); + calculator.update(currentBytes, totalBytes); + return calculator.speedAsString; + } + + /// Gets the current speed string for a specific task. + String getSpeedAsString(String taskId) { + return _calculators[taskId]?.speedAsString ?? '-- MB/s'; + } + + /// Gets the time remaining string for a specific task. + String getTimeRemainingAsString(String taskId) { + return _calculators[taskId]?.timeRemainingAsString ?? '--:--'; + } + + /// Removes a task from tracking. + void removeTask(String taskId) { + _calculators.remove(taskId); + } + + /// Clears all tracked tasks. + void clear() { + _calculators.clear(); + } +} diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 0bab675889..56b4802f88 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreService extends Mock implements StoreService {} @@ -16,5 +16,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {} class MockAppSettingsService extends Mock implements AppSettingsService {} -class MockUploadService extends Mock implements UploadService {} +class MockBackgroundUploadService extends Mock implements BackgroundUploadService {} diff --git a/mobile/test/drift/main/generated/schema_v16.dart b/mobile/test/drift/main/generated/schema_v16.dart index ce02845008..0690288d7f 100644 --- a/mobile/test/drift/main/generated/schema_v16.dart +++ b/mobile/test/drift/main/generated/schema_v16.dart @@ -5391,7 +5391,6 @@ class RemoteAssetCloudIdEntity extends Table true, type: DriftSqlType.string, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), ); late final GeneratedColumn createdAt = GeneratedColumn( 'created_at', diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index 1bad780ca7..7c7de3cd0e 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -21,7 +21,6 @@ void main() { late MockApiService apiService; late MockNetworkService networkService; late MockBackgroundSyncManager backgroundSyncManager; - late MockUploadService uploadService; late MockAppSettingService appSettingsService; late Isar db; @@ -31,7 +30,6 @@ void main() { apiService = MockApiService(); networkService = MockNetworkService(); backgroundSyncManager = MockBackgroundSyncManager(); - uploadService = MockUploadService(); appSettingsService = MockAppSettingService(); sut = AuthService( @@ -118,7 +116,6 @@ void main() { when(() => authApiRepository.logout()).thenAnswer((_) async => {}); when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {}); when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null)); - when(() => uploadService.cancelBackup()).thenAnswer((_) => Future.value(1)); when( () => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false), ).thenAnswer((_) => Future.value(null)); @@ -133,7 +130,6 @@ void main() { when(() => authApiRepository.logout()).thenThrow(Exception('Server error')); when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {}); when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null)); - when(() => uploadService.cancelBackup()).thenAnswer((_) => Future.value(1)); when( () => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false), ).thenAnswer((_) => Future.value(null)); diff --git a/mobile/test/services/upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart similarity index 82% rename from mobile/test/services/upload.service_test.dart rename to mobile/test/services/background_upload.service_test.dart index 86acf104e7..d0374c3987 100644 --- a/mobile/test/services/upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -12,13 +12,8 @@ import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; -import 'package:immich_mobile/models/server_info/server_config.model.dart'; -import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; -import 'package:immich_mobile/models/server_info/server_features.model.dart'; -import 'package:immich_mobile/models/server_info/server_info.model.dart'; -import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:mocktail/mocktail.dart'; import '../domain/service.mock.dart'; @@ -27,33 +22,12 @@ import '../infrastructure/repository.mock.dart'; import '../mocks/asset_entity.mock.dart'; import '../repository.mocks.dart'; -// Test ServerInfo stub -const _serverInfo = ServerInfo( - serverVersion: ServerVersion(major: 2, minor: 4, patch: 0), - latestVersion: ServerVersion(major: 2, minor: 4, patch: 0), - serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false), - serverConfig: ServerConfig( - trashDays: 30, - oauthButtonText: 'Login with OAuth', - externalDomain: '', - mapDarkStyleUrl: '', - mapLightStyleUrl: '', - ), - serverDiskInfo: ServerDiskInfo( - diskAvailable: '100GB', - diskSize: '500GB', - diskUse: '400GB', - diskUsagePercentage: 80.0, - ), - versionStatus: VersionStatus.upToDate, -); - void main() { - late UploadService sut; + late BackgroundUploadService sut; late MockUploadRepository mockUploadRepository; - late MockDriftBackupRepository mockBackupRepository; late MockStorageRepository mockStorageRepository; late MockDriftLocalAssetRepository mockLocalAssetRepository; + late MockDriftBackupRepository mockBackupRepository; late MockAppSettingsService mockAppSettingsService; late MockAssetMediaRepository mockAssetMediaRepository; late Drift db; @@ -75,23 +49,22 @@ void main() { setUp(() { mockUploadRepository = MockUploadRepository(); - mockBackupRepository = MockDriftBackupRepository(); mockStorageRepository = MockStorageRepository(); mockLocalAssetRepository = MockDriftLocalAssetRepository(); + mockBackupRepository = MockDriftBackupRepository(); mockAppSettingsService = MockAppSettingsService(); mockAssetMediaRepository = MockAssetMediaRepository(); when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false); when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false); - sut = UploadService( + sut = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); mockUploadRepository.onUploadStatus = (_) {}; @@ -201,14 +174,13 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final sutWithV24 = UploadService( + final sutWithV24 = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); addTearDown(() => sutWithV24.dispose()); @@ -247,61 +219,17 @@ void main() { expect(metadata[0]['value']['longitude'], isNotNull); }); - test('should NOT include metadata on iOS when server version is below 2.4', () async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - - final sutWithV23 = UploadService( - mockUploadRepository, - mockBackupRepository, - mockStorageRepository, - mockLocalAssetRepository, - mockAppSettingsService, - mockAssetMediaRepository, - _serverInfo.copyWith( - serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0), - latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0), - ), - ); - addTearDown(() => sutWithV23.dispose()); - - final assetWithCloudId = LocalAsset( - id: 'test-asset-id', - name: 'test.jpg', - type: AssetType.image, - createdAt: DateTime(2025, 1, 1), - updatedAt: DateTime(2025, 1, 2), - cloudId: 'cloud-id-123', - latitude: 37.7749, - longitude: -122.4194, - ); - - final mockEntity = MockAssetEntity(); - final mockFile = File('/path/to/test.jpg'); - - when(() => mockEntity.isLivePhoto).thenReturn(false); - when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); - when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); - when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); - - final task = await sutWithV23.getUploadTask(assetWithCloudId); - - expect(task, isNotNull); - expect(task!.fields.containsKey('metadata'), isFalse); - }); - test('should NOT include metadata on Android regardless of server version', () async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final sutAndroid = UploadService( + final sutAndroid = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); addTearDown(() => sutAndroid.dispose()); @@ -334,14 +262,13 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final sutWithV24 = UploadService( + final sutWithV24 = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); addTearDown(() => sutWithV24.dispose()); @@ -374,14 +301,13 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final sutWithV24 = UploadService( + final sutWithV24 = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); addTearDown(() => sutWithV24.dispose()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91cea5bcbf..c59846ad78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,10 +63,10 @@ importers: version: 4.13.4 '@types/node': specifier: ^24.10.4 - version: 24.10.7 + version: 24.10.9 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -108,16 +108,16 @@ importers: version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 - version: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -220,7 +220,7 @@ importers: version: 3.7.1 '@types/node': specifier: ^24.10.4 - version: 24.10.7 + version: 24.10.9 '@types/pg': specifier: ^8.15.1 version: 8.16.0 @@ -286,7 +286,7 @@ importers: version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -320,7 +320,7 @@ importers: devDependencies: '@types/node': specifier: ^24.10.4 - version: 24.10.7 + version: 24.10.9 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -500,7 +500,7 @@ importers: version: 2.0.2 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@types/inquirer@8.2.12)(@types/node@24.10.7)(typescript@5.9.3) + version: 3.20.1(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@types/inquirer@8.2.12)(@types/node@24.10.9)(typescript@5.9.3) nestjs-cls: specifier: ^5.0.0 version: 5.4.3(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -585,7 +585,7 @@ importers: version: 9.39.2 '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.14(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@24.10.7) + version: 11.0.14(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@24.10.9) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) @@ -639,7 +639,7 @@ importers: version: 2.0.0 '@types/node': specifier: ^24.10.4 - version: 24.10.7 + version: 24.10.9 '@types/nodemailer': specifier: ^7.0.0 version: 7.0.4 @@ -669,7 +669,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^9.14.0 version: 9.39.2(jiti@2.6.1) @@ -723,10 +723,10 @@ importers: version: 1.5.9(@swc/core@1.15.8(@swc/helpers@0.5.17))(rollup@4.55.1) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -741,7 +741,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.58.4 - version: 0.58.4(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) + version: 0.58.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -774,7 +774,7 @@ importers: version: 0.41.4 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.8(svelte@5.46.1) + version: 0.3.8(svelte@5.46.4) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -825,16 +825,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.46.1) + version: 4.0.1(svelte@5.46.4) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.46.1) + version: 3.11.0(svelte@5.46.4) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.5(svelte@5.46.1) + version: 1.2.5(svelte@5.46.4) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.46.1) + version: 0.12.0(svelte@5.46.4) tabbable: specifier: ^6.2.0 version: 6.4.0 @@ -859,16 +859,16 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.9.0 - version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.3 - version: 6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.18(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -877,7 +877,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -916,7 +916,7 @@ importers: version: 6.0.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.1) + version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.4) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -937,19 +937,19 @@ importers: version: 4.1.1(prettier@3.7.4) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.1(prettier@3.7.4)(svelte@5.46.1) + version: 3.4.1(prettier@3.7.4)(svelte@5.46.4) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.55.1) svelte: - specifier: 5.46.1 - version: 5.46.1 + specifier: 5.46.4 + version: 5.46.4 svelte-check: specifier: ^4.1.5 - version: 4.3.5(picomatch@4.0.3)(svelte@5.46.1)(typescript@5.9.3) + version: 4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.46.1) + version: 1.4.1(svelte@5.46.4) tailwindcss: specifier: ^4.1.7 version: 4.1.18 @@ -4382,7 +4382,10 @@ packages: svelte: ^5.0.0 '@immich/ui@0.58.4': - resolution: {integrity: sha512-/Y+TRA9E8VQ+yH0aqrkEnQTQi4j02dNgahil9NbJe3hSnakfDHZUgJR5xevGZbKqlnBV4O3mjbwmzr6j9wlP7w==} + resolution: + { + integrity: sha512-/Y+TRA9E8VQ+yH0aqrkEnQTQi4j02dNgahil9NbJe3hSnakfDHZUgJR5xevGZbKqlnBV4O3mjbwmzr6j9wlP7w==, + } peerDependencies: svelte: ^5.0.0 @@ -6716,10 +6719,10 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.49.3': + '@sveltejs/kit@2.49.5': resolution: { - integrity: sha512-luTmE2Isk9GRJnitqanLoByKBiyLdfLpV2qV9a25JMxjbQt919TVqG8pibJDkxTvX9+w2k/9IL7o+/RtG++3QA==, + integrity: sha512-dCYqelr2RVnWUuxc+Dk/dB/SjV/8JBndp1UovCyCZdIQezd8TRwFLNZctYkzgHxRJtaNvseCSRsuuHPeUgIN/A==, } engines: { node: '>=18.13' } hasBin: true @@ -7881,10 +7884,10 @@ packages: integrity: sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==, } - '@types/node@24.10.7': + '@types/node@24.10.9': resolution: { - integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==, + integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==, } '@types/node@25.0.7': @@ -10897,10 +10900,10 @@ packages: engines: { node: '>= 4.0.0' } hasBin: true - devalue@5.6.1: + devalue@5.6.2: resolution: { - integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==, + integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==, } devlop@1.1.0: @@ -18804,10 +18807,10 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.46.1: + svelte@5.46.4: resolution: { - integrity: sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==, + integrity: sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==, } engines: { node: '>=18' } @@ -20777,11 +20780,11 @@ snapshots: optionalDependencies: chokidar: 4.0.3 - '@angular-devkit/schematics-cli@19.2.19(@types/node@24.10.7)(chokidar@4.0.3)': + '@angular-devkit/schematics-cli@19.2.19(@types/node@24.10.9)(chokidar@4.0.3)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@inquirer/prompts': 7.3.2(@types/node@24.10.7) + '@inquirer/prompts': 7.3.2(@types/node@24.10.9) ansi-colors: 4.1.3 symbol-observable: 4.0.0 yargs-parser: 21.1.1 @@ -23671,19 +23674,19 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.46.1)': + '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.46.4)': dependencies: - svelte: 5.46.1 + svelte: 5.46.4 - '@immich/ui@0.58.4(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)': + '@immich/ui@0.58.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)': dependencies: - '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1) + '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.4) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) + bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4) luxon: 3.7.2 simple-icons: 16.4.0 - svelte: 5.46.1 + svelte: 5.46.4 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) @@ -23693,143 +23696,143 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@24.10.7)': + '@inquirer/checkbox@4.3.2(@types/node@24.10.9)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/type': 3.0.10(@types/node@24.10.9) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/confirm@5.1.21(@types/node@24.10.7)': + '@inquirer/confirm@5.1.21(@types/node@24.10.9)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.7) - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) + '@inquirer/type': 3.0.10(@types/node@24.10.9) optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/core@10.3.2(@types/node@24.10.7)': + '@inquirer/core@10.3.2(@types/node@24.10.9)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/type': 3.0.10(@types/node@24.10.9) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/editor@4.2.23(@types/node@24.10.7)': + '@inquirer/editor@4.2.23(@types/node@24.10.9)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.7) - '@inquirer/external-editor': 1.0.3(@types/node@24.10.7) - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) + '@inquirer/external-editor': 1.0.3(@types/node@24.10.9) + '@inquirer/type': 3.0.10(@types/node@24.10.9) optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/expand@4.0.23(@types/node@24.10.7)': + '@inquirer/expand@4.0.23(@types/node@24.10.9)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.7) - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) + '@inquirer/type': 3.0.10(@types/node@24.10.9) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/external-editor@1.0.3(@types/node@24.10.7)': + '@inquirer/external-editor@1.0.3(@types/node@24.10.9)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@24.10.7)': + '@inquirer/input@4.3.1(@types/node@24.10.9)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.7) - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) + '@inquirer/type': 3.0.10(@types/node@24.10.9) optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/number@3.0.23(@types/node@24.10.7)': + '@inquirer/number@3.0.23(@types/node@24.10.9)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.7) - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) + '@inquirer/type': 3.0.10(@types/node@24.10.9) optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/password@4.0.23(@types/node@24.10.7)': + '@inquirer/password@4.0.23(@types/node@24.10.9)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.7) - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) + '@inquirer/type': 3.0.10(@types/node@24.10.9) optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/prompts@7.10.1(@types/node@24.10.7)': + '@inquirer/prompts@7.10.1(@types/node@24.10.9)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.10.7) - '@inquirer/confirm': 5.1.21(@types/node@24.10.7) - '@inquirer/editor': 4.2.23(@types/node@24.10.7) - '@inquirer/expand': 4.0.23(@types/node@24.10.7) - '@inquirer/input': 4.3.1(@types/node@24.10.7) - '@inquirer/number': 3.0.23(@types/node@24.10.7) - '@inquirer/password': 4.0.23(@types/node@24.10.7) - '@inquirer/rawlist': 4.1.11(@types/node@24.10.7) - '@inquirer/search': 3.2.2(@types/node@24.10.7) - '@inquirer/select': 4.4.2(@types/node@24.10.7) + '@inquirer/checkbox': 4.3.2(@types/node@24.10.9) + '@inquirer/confirm': 5.1.21(@types/node@24.10.9) + '@inquirer/editor': 4.2.23(@types/node@24.10.9) + '@inquirer/expand': 4.0.23(@types/node@24.10.9) + '@inquirer/input': 4.3.1(@types/node@24.10.9) + '@inquirer/number': 3.0.23(@types/node@24.10.9) + '@inquirer/password': 4.0.23(@types/node@24.10.9) + '@inquirer/rawlist': 4.1.11(@types/node@24.10.9) + '@inquirer/search': 3.2.2(@types/node@24.10.9) + '@inquirer/select': 4.4.2(@types/node@24.10.9) optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/prompts@7.3.2(@types/node@24.10.7)': + '@inquirer/prompts@7.3.2(@types/node@24.10.9)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.10.7) - '@inquirer/confirm': 5.1.21(@types/node@24.10.7) - '@inquirer/editor': 4.2.23(@types/node@24.10.7) - '@inquirer/expand': 4.0.23(@types/node@24.10.7) - '@inquirer/input': 4.3.1(@types/node@24.10.7) - '@inquirer/number': 3.0.23(@types/node@24.10.7) - '@inquirer/password': 4.0.23(@types/node@24.10.7) - '@inquirer/rawlist': 4.1.11(@types/node@24.10.7) - '@inquirer/search': 3.2.2(@types/node@24.10.7) - '@inquirer/select': 4.4.2(@types/node@24.10.7) + '@inquirer/checkbox': 4.3.2(@types/node@24.10.9) + '@inquirer/confirm': 5.1.21(@types/node@24.10.9) + '@inquirer/editor': 4.2.23(@types/node@24.10.9) + '@inquirer/expand': 4.0.23(@types/node@24.10.9) + '@inquirer/input': 4.3.1(@types/node@24.10.9) + '@inquirer/number': 3.0.23(@types/node@24.10.9) + '@inquirer/password': 4.0.23(@types/node@24.10.9) + '@inquirer/rawlist': 4.1.11(@types/node@24.10.9) + '@inquirer/search': 3.2.2(@types/node@24.10.9) + '@inquirer/select': 4.4.2(@types/node@24.10.9) optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/rawlist@4.1.11(@types/node@24.10.7)': + '@inquirer/rawlist@4.1.11(@types/node@24.10.9)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.7) - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) + '@inquirer/type': 3.0.10(@types/node@24.10.9) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/search@3.2.2(@types/node@24.10.7)': + '@inquirer/search@3.2.2(@types/node@24.10.9)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/type': 3.0.10(@types/node@24.10.9) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/select@4.4.2(@types/node@24.10.7)': + '@inquirer/select@4.4.2(@types/node@24.10.9)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.7) + '@inquirer/core': 10.3.2(@types/node@24.10.9) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.7) + '@inquirer/type': 3.0.10(@types/node@24.10.9) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 - '@inquirer/type@3.0.10(@types/node@24.10.7)': + '@inquirer/type@3.0.10(@types/node@24.10.9)': optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@internationalized/date@3.10.0': dependencies: @@ -23869,7 +23872,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -24174,12 +24177,12 @@ snapshots: bullmq: 5.66.4 tslib: 2.8.1 - '@nestjs/cli@11.0.14(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@24.10.7)': + '@nestjs/cli@11.0.14(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@24.10.9)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.10.7)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@24.10.7) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.10.9)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.10.9) '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) ansis: 4.2.0 chokidar: 4.0.3 @@ -25300,17 +25303,17 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.46.1 - svelte-parse-markup: 0.1.5(svelte@5.46.1) + svelte: 5.46.4 + svelte-parse-markup: 0.1.5(svelte@5.46.4) vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.2(rollup@4.55.1) zimmerframe: 1.1.4 @@ -25318,15 +25321,15 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 - devalue: 5.6.1 + devalue: 5.6.2 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -25334,28 +25337,28 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.7.2 sirv: 3.0.2 - svelte: 5.46.1 + svelte: 5.46.4 vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 - svelte: 5.46.1 + svelte: 5.46.4 vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.46.1 + svelte: 5.46.4 vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: @@ -25603,15 +25606,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.46.1)': + '@testing-library/svelte-core@1.0.0(svelte@5.46.4)': dependencies: - svelte: 5.46.1 + svelte: 5.46.4 - '@testing-library/svelte@5.3.1(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.0.0(svelte@5.46.1) - svelte: 5.46.1 + '@testing-library/svelte-core': 1.0.0(svelte@5.46.4) + svelte: 5.46.4 optionalDependencies: vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -25655,7 +25658,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/archiver@7.0.0': dependencies: @@ -25667,16 +25670,16 @@ snapshots: '@types/bcrypt@6.0.0': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/bonjour@3.5.13': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/braces@3.0.5': {} @@ -25698,21 +25701,21 @@ snapshots: '@types/cli-progress@3.11.6': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/compression@1.8.1': dependencies: '@types/express': 5.0.6 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/connect@3.4.38': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/content-disposition@0.5.9': {} @@ -25729,11 +25732,11 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.6 '@types/keygrip': 1.0.6 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/cors@2.8.19': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/d3-array@3.2.2': {} @@ -25860,13 +25863,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/ssh2': 1.15.5 '@types/dockerode@3.3.47': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/ssh2': 1.15.5 '@types/dom-to-image@2.6.7': {} @@ -25889,14 +25892,14 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -25922,7 +25925,7 @@ snapshots: '@types/fluent-ffmpeg@2.1.28': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/geojson-vt@3.2.5': dependencies: @@ -25954,7 +25957,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/inquirer@8.2.12': dependencies: @@ -25978,7 +25981,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/justified-layout@4.1.4': {} @@ -25997,7 +26000,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.9 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/leaflet@1.9.21': dependencies: @@ -26027,7 +26030,7 @@ snapshots: '@types/mock-fs@4.13.4': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/ms@2.1.0': {} @@ -26037,7 +26040,7 @@ snapshots: '@types/node-forge@1.3.14': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/node@17.0.45': {} @@ -26049,7 +26052,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.10.7': + '@types/node@24.10.9': dependencies: undici-types: 7.16.0 @@ -26061,7 +26064,7 @@ snapshots: '@types/nodemailer@7.0.4': dependencies: '@aws-sdk/client-sesv2': 3.952.0 - '@types/node': 24.10.7 + '@types/node': 24.10.9 transitivePeerDependencies: - aws-crt @@ -26069,7 +26072,7 @@ snapshots: dependencies: '@types/keygrip': 1.0.6 '@types/koa': 3.0.1 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/parse5@5.0.3': {} @@ -26079,13 +26082,13 @@ snapshots: '@types/pg@8.15.6': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 pg-protocol: 1.10.3 pg-types: 2.2.0 '@types/pg@8.16.0': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -26093,13 +26096,13 @@ snapshots: '@types/pngjs@6.0.5': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/prismjs@1.26.5': {} '@types/qrcode@1.5.6': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/qs@6.14.0': {} @@ -26128,7 +26131,7 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/retry@0.12.2': {} @@ -26138,18 +26141,18 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/semver@7.7.1': {} '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/send@1.2.1': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/serve-index@1.9.4': dependencies: @@ -26158,25 +26161,25 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/send': 0.17.6 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/sockjs@0.3.36': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/ssh2@0.5.52': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -26187,7 +26190,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 24.10.7 + '@types/node': 24.10.9 form-data: 4.0.5 '@types/supercluster@7.1.3': @@ -26201,7 +26204,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/trusted-types@2.0.7': optional: true @@ -26218,7 +26221,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 '@types/yargs-parser@21.0.3': {} @@ -26321,7 +26324,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -26336,7 +26339,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -26367,13 +26370,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -26493,10 +26496,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.8(svelte@5.46.1)': + '@zoom-image/svelte@0.3.8(svelte@5.46.4)': dependencies: '@zoom-image/core': 0.41.4 - svelte: 5.46.1 + svelte: 5.46.4 abab@2.0.6: optional: true @@ -26857,15 +26860,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1): + bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) - svelte: 5.46.1 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) + runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4) + svelte: 5.46.4 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -27970,7 +27973,7 @@ snapshots: transitivePeerDependencies: - supports-color - devalue@5.6.1: {} + devalue@5.6.2: {} devlop@1.1.0: dependencies: @@ -28174,7 +28177,7 @@ snapshots: engine.io@6.6.5: dependencies: '@types/cors': 2.8.19 - '@types/node': 24.10.7 + '@types/node': 24.10.9 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -28393,7 +28396,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.1): + eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.4): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -28405,9 +28408,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.3 - svelte-eslint-parser: 1.4.1(svelte@5.46.1) + svelte-eslint-parser: 1.4.1(svelte@5.46.4) optionalDependencies: - svelte: 5.46.1 + svelte: 5.46.4 transitivePeerDependencies: - ts-node @@ -28570,7 +28573,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 require-like: 0.1.2 event-emitter@0.3.5: @@ -29579,9 +29582,9 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@8.2.7(@types/node@24.10.7): + inquirer@8.2.7(@types/node@24.10.9): dependencies: - '@inquirer/external-editor': 1.0.3(@types/node@24.10.7) + '@inquirer/external-editor': 1.0.3(@types/node@24.10.9) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 @@ -29818,7 +29821,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.10.7 + '@types/node': 24.10.9 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -29826,13 +29829,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -31157,7 +31160,7 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@types/inquirer@8.2.12)(@types/node@24.10.7)(typescript@5.9.3): + nest-commander@3.20.1(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@types/inquirer@8.2.12)(@types/node@24.10.9)(typescript@5.9.3): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11) @@ -31166,7 +31169,7 @@ snapshots: '@types/inquirer': 8.2.12 commander: 11.1.0 cosmiconfig: 8.3.6(typescript@5.9.3) - inquirer: 8.2.7(@types/node@24.10.7) + inquirer: 8.2.7(@types/node@24.10.9) transitivePeerDependencies: - '@types/node' - typescript @@ -32205,10 +32208,10 @@ snapshots: dependencies: prettier: 3.7.4 - prettier-plugin-svelte@3.4.1(prettier@3.7.4)(svelte@5.46.1): + prettier-plugin-svelte@3.4.1(prettier@3.7.4)(svelte@5.46.4): dependencies: prettier: 3.7.4 - svelte: 5.46.1 + svelte: 5.46.4 prettier@3.7.4: {} @@ -32285,7 +32288,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.10.7 + '@types/node': 24.10.9 long: 5.3.2 protocol-buffers-schema@3.6.0: {} @@ -32820,14 +32823,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1): + runed@0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.46.1 + svelte: 5.46.4 optionalDependencies: - '@sveltejs/kit': 2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -33457,23 +33460,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.46.1): + svelte-awesome@3.3.5(svelte@5.46.4): dependencies: - svelte: 5.46.1 + svelte: 5.46.4 - svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.1)(typescript@5.9.3): + svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.46.1 + svelte: 5.46.4 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.46.1): + svelte-eslint-parser@1.4.1(svelte@5.46.4): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -33482,7 +33485,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.46.1 + svelte: 5.46.4 svelte-floating-ui@1.5.8: dependencies: @@ -33495,7 +33498,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.46.1): + svelte-i18n@4.0.1(svelte@5.46.4): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -33503,10 +33506,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.46.1 + svelte: 5.46.4 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.46.1): + svelte-jsoneditor@3.11.0(svelte@5.46.4): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -33533,42 +33536,42 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.46.1 - svelte-awesome: 3.3.5(svelte@5.46.1) + svelte: 5.46.4 + svelte-awesome: 3.3.5(svelte@5.46.4) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.5(svelte@5.46.1): + svelte-maplibre@1.2.5(svelte@5.46.4): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 maplibre-gl: 5.15.0 pmtiles: 3.2.1 - svelte: 5.46.1 + svelte: 5.46.4 - svelte-parse-markup@0.1.5(svelte@5.46.1): + svelte-parse-markup@0.1.5(svelte@5.46.4): dependencies: - svelte: 5.46.1 + svelte: 5.46.4 - svelte-persisted-store@0.12.0(svelte@5.46.1): + svelte-persisted-store@0.12.0(svelte@5.46.4): dependencies: - svelte: 5.46.1 + svelte: 5.46.4 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.49.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.1)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) + runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4) style-to-object: 1.0.14 - svelte: 5.46.1 + svelte: 5.46.4 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.46.1: + svelte@5.46.4: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -33578,7 +33581,7 @@ snapshots: aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.6.1 + devalue: 5.6.2 esm-env: 1.2.2 esrap: 2.2.1 is-reference: 3.0.3 @@ -34273,13 +34276,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -34315,18 +34318,18 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -34335,7 +34338,7 @@ snapshots: rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.7 + '@types/node': 24.10.9 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -34366,15 +34369,15 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -34392,12 +34395,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.10.7 + '@types/node': 24.10.9 happy-dom: 20.1.0 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: @@ -34414,11 +34417,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.7)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -34436,12 +34439,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.10.7 + '@types/node': 24.10.9 happy-dom: 20.1.0 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: diff --git a/web/package.json b/web/package.json index a567fac88a..d0971ac3fc 100644 --- a/web/package.json +++ b/web/package.json @@ -97,7 +97,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.46.1", + "svelte": "5.46.4", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts index e49f04dbee..872d3d03bf 100644 --- a/web/src/lib/actions/thumbhash.ts +++ b/web/src/lib/actions/thumbhash.ts @@ -3,17 +3,27 @@ import { thumbHashToRGBA } from 'thumbhash'; /** * Renders a thumbnail onto a canvas from a base64 encoded hash. - * @param canvas - * @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString) */ -export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { - const ctx = canvas.getContext('2d'); - if (ctx) { - const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash)); - const pixels = ctx.createImageData(w, h); - canvas.width = w; - canvas.height = h; - pixels.data.set(rgba); - ctx.putImageData(pixels, 0, 0); - } +export function thumbhash(canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) { + render(canvas, options); + + return { + update(newOptions: { base64ThumbHash: string }) { + render(canvas, newOptions); + }, + }; } + +const render = (canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) => { + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + const { w, h, rgba } = thumbHashToRGBA(decodeBase64(options.base64ThumbHash)); + const pixels = ctx.createImageData(w, h); + canvas.width = w; + canvas.height = h; + pixels.data.set(rgba); + ctx.putImageData(pixels, 0, 0); +}; diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte deleted file mode 100644 index 35c5c8585b..0000000000 --- a/web/src/lib/components/AdminSidebar.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - -
-
- - - - - - -
- -
- -
-
diff --git a/web/src/lib/components/BreadcrumbActionPage.svelte b/web/src/lib/components/BreadcrumbActionPage.svelte new file mode 100644 index 0000000000..cdde67b725 --- /dev/null +++ b/web/src/lib/components/BreadcrumbActionPage.svelte @@ -0,0 +1,61 @@ + + +
+
+ + + {#if enabledActions.length > 0} + + + + {/if} +
+ + + +
diff --git a/web/src/lib/components/layouts/AdminPageLayout.svelte b/web/src/lib/components/layouts/AdminPageLayout.svelte index 6ec982d132..8bb60086c1 100644 --- a/web/src/lib/components/layouts/AdminPageLayout.svelte +++ b/web/src/lib/components/layouts/AdminPageLayout.svelte @@ -1,23 +1,12 @@ - + - - + +
+ + + + + + +
+ +
+ +
-
-
- - - {#if enabledActions.length > 0} - - - - {/if} -
- - - {@render children?.()} - - -
+ + {@render children?.()} +
diff --git a/web/src/lib/components/layouts/PageContent.svelte b/web/src/lib/components/layouts/PageContent.svelte deleted file mode 100644 index 150aaecf43..0000000000 --- a/web/src/lib/components/layouts/PageContent.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 500e66c8da..614b1377fb 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -14,13 +14,11 @@ interface Props { hideNavbar?: boolean; - showUploadButton?: boolean; title?: string | undefined; description?: string | undefined; scrollbar?: boolean; use?: ActionArray; actions?: Array; - header?: Snippet; sidebar?: Snippet; buttons?: Snippet; children?: Snippet; @@ -28,13 +26,11 @@ let { hideNavbar = false, - showUploadButton = false, title = undefined, description = undefined, scrollbar = true, use = [], actions = [], - header, sidebar, buttons, children, @@ -52,10 +48,8 @@
{#if !hideNavbar} - openFileUploadDialog()} /> + openFileUploadDialog()} /> {/if} - - {@render header?.()}
void; // TODO: remove once this is only used in noBorder?: boolean; - } + }; - let { showUploadButton = true, onUploadClick, noBorder = false }: Props = $props(); + let { onUploadClick, noBorder = false }: Props = $props(); let shouldShowAccountInfoPanel = $state(false); let shouldShowNotificationPanel = $state(false); @@ -105,7 +104,7 @@ /> {/if} - {#if !page.url.pathname.includes('/admin') && showUploadButton && onUploadClick} + {#if !page.url.pathname.includes('/admin') && onUploadClick}