mirror of
https://github.com/immich-app/immich.git
synced 2026-05-20 06:22:18 -04:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 653c4db355 | |||
| 915d865ce2 | |||
| c28e5f90b6 | |||
| 4383473ed6 | |||
| 77701dd5a3 | |||
| d4808fdc4d | |||
| 7fa967a98e | |||
| 9cffcc9f4e | |||
| 40925f0a06 | |||
| 0544d22902 | |||
| 3d075f2bf8 | |||
| 7384799f19 | |||
| 4a7f06e8fd | |||
| 8f662fc459 | |||
| 24b1dae9f2 | |||
| 3a3469a5f9 | |||
| 7993619ed2 | |||
| 4d1f6f869b | |||
| 3eb03f7934 | |||
| 03ed3daa31 | |||
| 02581e81a7 | |||
| 3ab3d5cf43 | |||
| 0ef04d9baa | |||
| df016f9228 | |||
| 17779c1e74 | |||
| 01d6a244d8 |
@@ -16,7 +16,7 @@ services:
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../packages/plugins:/build/corePlugin
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
immich-machine-learning:
|
||||
|
||||
@@ -159,14 +159,14 @@ jobs:
|
||||
|
||||
- name: Comment APK download link on PR
|
||||
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
|
||||
uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'mobile-android-apk'
|
||||
message: |
|
||||
id: mobile-android-apk
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
body: |
|
||||
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
|
||||
|
||||
Download: ${{ env.APK_URL }}
|
||||
|
||||
@@ -213,12 +213,11 @@ jobs:
|
||||
run: 'mise run //deployment:tf apply'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
||||
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
if: ${{ steps.parameters.outputs.event == 'pr' }}
|
||||
with:
|
||||
id: docs-pr-url
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
|
||||
body: |
|
||||
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
|
||||
emojis: 'rocket'
|
||||
body-include: '<!-- Docs PR URL -->'
|
||||
|
||||
@@ -44,9 +44,8 @@ jobs:
|
||||
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
||||
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
with:
|
||||
id: docs-pr-url
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
number: ${{ github.event.number }}
|
||||
delete: true
|
||||
body-include: '<!-- Docs PR URL -->'
|
||||
|
||||
@@ -19,11 +19,11 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
|
||||
id: preview-status
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
body: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
|
||||
|
||||
remove-label:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,16 +48,16 @@ jobs:
|
||||
name: 'preview'
|
||||
})
|
||||
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
if: ${{ github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'PRs from forks cannot have preview environments.'
|
||||
id: preview-status
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
body: 'PRs from forks cannot have preview environments.'
|
||||
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'Preview environment has been removed.'
|
||||
id: preview-status
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
body: 'Preview environment has been removed.'
|
||||
|
||||
+10
-14
@@ -62,9 +62,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
@@ -84,7 +81,7 @@ jobs:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-unit
|
||||
run: mise run ci-unit
|
||||
run: mise run //server:ci-unit
|
||||
|
||||
cli-unit-tests:
|
||||
name: Unit Test CLI
|
||||
@@ -380,7 +377,7 @@ jobs:
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Setup packages
|
||||
run: pnpm --filter "@immich/*" install --frozen-lockfile && pnpm --filter "@immich/*" build
|
||||
run: pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && pnpm --filter @immich/sdk --filter @immich/cli build
|
||||
|
||||
- name: Run setup web
|
||||
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
|
||||
@@ -678,7 +675,6 @@ jobs:
|
||||
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
|
||||
|
||||
- name: Run API generation
|
||||
run: mise //:open-api
|
||||
working-directory: open-api
|
||||
@@ -717,9 +713,6 @@ jobs:
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
@@ -741,18 +734,21 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build plugins
|
||||
run: mise //:plugins
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
run: mise //server:build
|
||||
|
||||
- name: Run existing migrations
|
||||
run: pnpm migrations:run
|
||||
run: pnpm --filter immich migrations:run
|
||||
|
||||
- name: Test npm run schema:reset command works
|
||||
run: pnpm schema:reset
|
||||
run: pnpm --filter immich schema:reset
|
||||
|
||||
- name: Generate new migrations
|
||||
continue-on-error: true
|
||||
run: pnpm migrations:generate src/TestMigration
|
||||
run: pnpm --filter migrations:generate src/TestMigration
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
@@ -768,7 +764,7 @@ jobs:
|
||||
run: |
|
||||
echo "ERROR: Generated migration files not up to date!"
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
cat ./src/*-TestMigration.ts
|
||||
cat ./server/src/*-TestMigration.ts
|
||||
exit 1
|
||||
|
||||
- name: Run SQL generation
|
||||
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../packages/plugins:/build/corePlugin
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -18,7 +18,7 @@ make e2e
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
- `pnpm install`
|
||||
- `pnpm --filter "@immich/*" build`
|
||||
- `pnpm --filter @immich/sdk --filter @immich/cli build`
|
||||
- `mise //:open-api`
|
||||
|
||||
Once the test environment is running, the e2e tests can be run via:
|
||||
|
||||
+41
-10
@@ -22,13 +22,12 @@
|
||||
"add_birthday": "Add a birthday",
|
||||
"add_endpoint": "Add endpoint",
|
||||
"add_exclusion_pattern": "Add exclusion pattern",
|
||||
"add_filter": "Add filter",
|
||||
"add_filter_description": "Click to add a filter condition",
|
||||
"add_location": "Add location",
|
||||
"add_more_users": "Add more users",
|
||||
"add_partner": "Add partner",
|
||||
"add_path": "Add path",
|
||||
"add_photos": "Add photos",
|
||||
"add_step": "Add step",
|
||||
"add_tag": "Add tag",
|
||||
"add_to": "Add to…",
|
||||
"add_to_album": "Add to album",
|
||||
@@ -42,7 +41,6 @@
|
||||
"add_to_shared_album": "Add to shared album",
|
||||
"add_upload_to_stack": "Add upload to stack",
|
||||
"add_url": "Add URL",
|
||||
"add_workflow_step": "Add workflow step",
|
||||
"added_to_archive": "Added to archive",
|
||||
"added_to_favorites": "Added to favorites",
|
||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||
@@ -467,10 +465,14 @@
|
||||
"advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]",
|
||||
"advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
|
||||
"advanced_settings_readonly_mode_title": "Read-only mode",
|
||||
"advanced_settings_review_remote_deletions_subtitle": "Manually review cloud trash changes. Restorations are applied automatically.",
|
||||
"advanced_settings_review_remote_deletions_title": "Review remote deletions",
|
||||
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
|
||||
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
|
||||
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
|
||||
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
|
||||
"advanced_settings_sync_remote_deletions_off_subtitle": "Cloud trash changes are ignored",
|
||||
"advanced_settings_sync_remote_deletions_selector_title": "Sync remote deletions [EXPERIMENTAL]",
|
||||
"advanced_settings_sync_remote_deletions_subtitle": "Automatically move assets to trash or restore them on this device when that action is taken on the web.",
|
||||
"advanced_settings_sync_remote_deletions_title": "Auto sync",
|
||||
"advanced_settings_tile_subtitle": "Advanced user's settings",
|
||||
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
|
||||
"advanced_settings_troubleshooting_title": "Troubleshooting",
|
||||
@@ -581,6 +583,11 @@
|
||||
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
|
||||
"asset_offline": "Asset Offline",
|
||||
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
|
||||
"asset_out_of_sync_title": "Out-of-sync assets list",
|
||||
"asset_out_of_sync_trash_confirmation_text": "Move {count, plural, one {asset} other {# assets}} to your device trash?",
|
||||
"asset_out_of_sync_trash_confirmation_title": "Sync trash change",
|
||||
"asset_out_of_sync_trash_subtitle": "Assets moved to the Immich cloud trash: choose to move them to local trash or keep them on this device.",
|
||||
"asset_out_of_sync_trash_subtitle_result": "Nothing left to review — all decisions applied.",
|
||||
"asset_restored_successfully": "Asset restored successfully",
|
||||
"asset_skipped": "Skipped",
|
||||
"asset_skipped_in_trash": "In trash",
|
||||
@@ -599,6 +606,7 @@
|
||||
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
||||
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
|
||||
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
|
||||
"assets_denied_to_moved_to_trash_count": "Keeping local copies of {count, plural, one {# asset} other {# assets}}",
|
||||
"assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}",
|
||||
"assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}",
|
||||
"assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
|
||||
@@ -733,6 +741,7 @@
|
||||
"cannot_update_the_description": "Cannot update the description",
|
||||
"cast": "Cast",
|
||||
"cast_description": "Configure available cast destinations",
|
||||
"change": "Change",
|
||||
"change_date": "Change date",
|
||||
"change_description": "Change description",
|
||||
"change_display_order": "Change display order",
|
||||
@@ -761,6 +770,7 @@
|
||||
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
||||
"check_logs": "Check Logs",
|
||||
"checksum": "Checksum",
|
||||
"choose": "Choose",
|
||||
"choose_matching_people_to_merge": "Choose matching people to merge",
|
||||
"city": "City",
|
||||
"cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
|
||||
@@ -778,6 +788,7 @@
|
||||
"clear": "Clear",
|
||||
"clear_all": "Clear all",
|
||||
"clear_all_recent_searches": "Clear all recent searches",
|
||||
"clear_failed_count": "Clear failed ({count})",
|
||||
"clear_file_cache": "Clear File Cache",
|
||||
"clear_message": "Clear message",
|
||||
"clear_value": "Clear value",
|
||||
@@ -809,6 +820,7 @@
|
||||
"comments_are_disabled": "Comments are disabled",
|
||||
"common_create_new_album": "Create new album",
|
||||
"completed": "Completed",
|
||||
"configuration": "Configuration",
|
||||
"confirm": "Confirm",
|
||||
"confirm_admin_password": "Confirm Admin Password",
|
||||
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
|
||||
@@ -823,6 +835,7 @@
|
||||
"contain": "Contain",
|
||||
"context": "Context",
|
||||
"continue": "Continue",
|
||||
"control_bottom_app_bar_add_tags": "Add Tags",
|
||||
"control_bottom_app_bar_create_new_album": "Create new album",
|
||||
"control_bottom_app_bar_delete_from_immich": "Delete from Immich",
|
||||
"control_bottom_app_bar_delete_from_local": "Delete from device",
|
||||
@@ -894,6 +907,7 @@
|
||||
"date_of_birth": "Date of birth",
|
||||
"date_of_birth_saved": "Date of birth saved successfully",
|
||||
"date_range": "Date range",
|
||||
"date_time_original": "Date/Time Original",
|
||||
"day": "Day",
|
||||
"days": "Days",
|
||||
"deduplicate_all": "Deduplicate All",
|
||||
@@ -1074,6 +1088,7 @@
|
||||
"failed_to_remove_product_key": "Failed to remove product key",
|
||||
"failed_to_reset_pin_code": "Failed to reset PIN code",
|
||||
"failed_to_stack_assets": "Failed to stack assets",
|
||||
"failed_to_tag_assets": "Failed to tag assets",
|
||||
"failed_to_unstack_assets": "Failed to un-stack assets",
|
||||
"failed_to_update_notification_status": "Failed to update notification status",
|
||||
"incorrect_email_or_password": "Incorrect email or password",
|
||||
@@ -1193,11 +1208,13 @@
|
||||
"export_as_json": "Export as JSON",
|
||||
"export_database": "Export Database",
|
||||
"export_database_description": "Export the SQLite database",
|
||||
"exposure_time": "Exposure Time",
|
||||
"extension": "Extension",
|
||||
"external": "External",
|
||||
"external_libraries": "External Libraries",
|
||||
"external_network": "External network",
|
||||
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
|
||||
"f_number": "F-Number",
|
||||
"face_unassigned": "Unassigned",
|
||||
"failed": "Failed",
|
||||
"failed_count": "Failed: {count}",
|
||||
@@ -1215,7 +1232,6 @@
|
||||
"features_setting_description": "Manage the app features",
|
||||
"file_name_or_extension": "File name or extension",
|
||||
"file_name_text": "File name",
|
||||
"file_name_with_value": "File name: {file_name}",
|
||||
"file_size": "File size",
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
@@ -1228,6 +1244,7 @@
|
||||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
"fix_incorrect_match": "Fix incorrect match",
|
||||
"focal_length": "Focal Length",
|
||||
"folder": "Folder",
|
||||
"folder_not_found": "Folder not found",
|
||||
"folders": "Folders",
|
||||
@@ -1348,6 +1365,7 @@
|
||||
"ios_debug_info_no_sync_yet": "No background sync job has run yet",
|
||||
"ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}",
|
||||
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
|
||||
"iso": "ISO",
|
||||
"items_count": "{count, plural, one {# item} other {# items}}",
|
||||
"jobs": "Jobs",
|
||||
"json_editor": "JSON editor",
|
||||
@@ -1358,6 +1376,7 @@
|
||||
"keep_all": "Keep All",
|
||||
"keep_description": "Choose what stays on your device when freeing up space.",
|
||||
"keep_favorites": "Keep favorites",
|
||||
"keep_in_trash": "Keep in trash",
|
||||
"keep_on_device": "Keep on device",
|
||||
"keep_on_device_hint": "Select items to keep on this device",
|
||||
"keep_this_delete_others": "Keep this, delete others",
|
||||
@@ -1580,6 +1599,7 @@
|
||||
"mobile_app": "Mobile App",
|
||||
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
|
||||
"model": "Model",
|
||||
"modify_date": "Modify Date",
|
||||
"month": "Month",
|
||||
"more": "More",
|
||||
"motion": "Motion",
|
||||
@@ -1628,7 +1648,6 @@
|
||||
"next": "Next",
|
||||
"next_memory": "Next memory",
|
||||
"no": "No",
|
||||
"no_actions_added": "No actions added yet",
|
||||
"no_albums_found": "No albums found",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
@@ -1645,7 +1664,6 @@
|
||||
"no_exif_info_available": "No exif info available",
|
||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||
"no_filters_added": "No filters added yet",
|
||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||
"no_local_assets_found": "No local assets found with this checksum",
|
||||
"no_location_set": "No location set",
|
||||
@@ -1658,6 +1676,7 @@
|
||||
"no_results": "No results",
|
||||
"no_results_description": "Try a synonym or more general keyword",
|
||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||
"no_steps": "No steps added yet",
|
||||
"no_uploads_in_progress": "No uploads in progress",
|
||||
"none": "None",
|
||||
"not_allowed": "Not allowed",
|
||||
@@ -1677,6 +1696,7 @@
|
||||
"obtainium_configurator": "Obtainium Configurator",
|
||||
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
|
||||
"ocr": "OCR",
|
||||
"off": "Off",
|
||||
"official_immich_resources": "Official Immich Resources",
|
||||
"offline": "Offline",
|
||||
"offset": "Offset",
|
||||
@@ -1703,6 +1723,7 @@
|
||||
"organize_into_albums": "Organize into albums",
|
||||
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
|
||||
"organize_your_library": "Organize your library",
|
||||
"orientation": "Orientation",
|
||||
"original": "original",
|
||||
"other": "Other",
|
||||
"other_devices": "Other devices",
|
||||
@@ -1794,6 +1815,8 @@
|
||||
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
|
||||
"play_transcoded_video": "Play transcoded video",
|
||||
"please_auth_to_access": "Please authenticate to access",
|
||||
"plugin_method_filter_type": "Filter",
|
||||
"plugin_method_filter_type_description": "This method can filter events and conditionally prevent subsequent steps from running",
|
||||
"port": "Port",
|
||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||
"preferences_settings_title": "Preferences",
|
||||
@@ -1815,6 +1838,7 @@
|
||||
"profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.",
|
||||
"profile_image_of_user": "Profile image of {user}",
|
||||
"profile_picture_set": "Profile picture set.",
|
||||
"projection_type": "Projection Type",
|
||||
"public_album": "Public album",
|
||||
"public_share": "Public Share",
|
||||
"purchase_account_info": "Supporter",
|
||||
@@ -1947,6 +1971,7 @@
|
||||
"retry_upload": "Retry upload",
|
||||
"review_duplicates": "Review duplicates",
|
||||
"review_large_files": "Review large files",
|
||||
"review_out_of_sync_changes": "Review out-of-sync changes",
|
||||
"role": "Role",
|
||||
"role_editor": "Editor",
|
||||
"role_viewer": "Viewer",
|
||||
@@ -2184,7 +2209,9 @@
|
||||
"show_in_timeline": "Show in timeline",
|
||||
"show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
|
||||
"show_keyboard_shortcuts": "Show keyboard shortcuts",
|
||||
"show_less": "Show less",
|
||||
"show_metadata": "Show metadata",
|
||||
"show_more_fields": "{count, plural, one {Show # more field} other {Show # more fields}}",
|
||||
"show_or_hide_info": "Show or hide info",
|
||||
"show_password": "Show password",
|
||||
"show_person_options": "Show person options",
|
||||
@@ -2236,6 +2263,10 @@
|
||||
"start_date_before_end_date": "Start date must be before end date",
|
||||
"state": "State",
|
||||
"status": "Status",
|
||||
"step_delete": "Delete step",
|
||||
"step_delete_confirm": "Are you sure you want to delete this step?",
|
||||
"step_details": "Step details",
|
||||
"steps": "Steps",
|
||||
"stop_casting": "Stop casting",
|
||||
"stop_motion_photo": "Stop Motion Photo",
|
||||
"stop_photo_sharing": "Stop sharing your photos?",
|
||||
@@ -2329,7 +2360,7 @@
|
||||
"trash_page_title": "Trash ({count})",
|
||||
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
|
||||
"trigger": "Trigger",
|
||||
"trigger_asset_uploaded": "Asset Uploaded",
|
||||
"trigger_asset_uploaded": "Asset Upload",
|
||||
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
|
||||
"trigger_description": "An event that kicks off the workflow",
|
||||
"trigger_person_recognized": "Person Recognized",
|
||||
@@ -2369,7 +2400,6 @@
|
||||
"unsupported_field_type": "Unsupported field type",
|
||||
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
|
||||
"untagged": "Untagged",
|
||||
"untitled_workflow": "Untitled workflow",
|
||||
"up_next": "Up next",
|
||||
"update_location_action_prompt": "Update the location of {count} selected assets with:",
|
||||
"updated_at": "Updated",
|
||||
@@ -2461,6 +2491,7 @@
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
"workflow": "Workflow",
|
||||
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
|
||||
"workflow_deleted": "Workflow deleted",
|
||||
"workflow_description": "Workflow description",
|
||||
|
||||
@@ -2,7 +2,7 @@ experimental_monorepo_root = true
|
||||
|
||||
[monorepo]
|
||||
config_roots = [
|
||||
"packages/plugins",
|
||||
"packages/plugin-core",
|
||||
"server",
|
||||
"packages/cli",
|
||||
"deployment",
|
||||
@@ -22,12 +22,22 @@ terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
java = "21.0.2"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
"github:webassembly/binaryen" = "version_124"
|
||||
"github:extism/js-pdk" = "1.6.0"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.37.0"
|
||||
bin = "dcm"
|
||||
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm".platforms]
|
||||
linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
|
||||
linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
|
||||
macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
|
||||
macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
|
||||
windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"]
|
||||
version = "7.1.3-6"
|
||||
|
||||
@@ -41,6 +51,12 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
|
||||
experimental = true
|
||||
pin = true
|
||||
|
||||
[tasks.plugins]
|
||||
run = [
|
||||
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
|
||||
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build"
|
||||
]
|
||||
|
||||
[tasks.open-api-typescript]
|
||||
run = [
|
||||
"oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts",
|
||||
@@ -55,6 +71,8 @@ run = "bash ./bin/generate-dart-sdk.sh"
|
||||
[tasks.open-api]
|
||||
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
|
||||
run = [
|
||||
{ task = "//:plugins" },
|
||||
{ task = "//server:build" },
|
||||
{ task = "//server:install" },
|
||||
{ task = "//server:build" },
|
||||
{ task = "//server:sync-open-api" },
|
||||
|
||||
@@ -89,6 +89,13 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
constraints {
|
||||
implementation("androidx.glance:glance-appwidget") {
|
||||
version { strictly libs.versions.glance.get() }
|
||||
because 'home_widget requests 1.+ which can resolve to pre-releases incompatible with our compileSdk/AGP'
|
||||
}
|
||||
}
|
||||
|
||||
implementation libs.okhttp
|
||||
implementation libs.cronet.embedded
|
||||
implementation libs.media3.datasource.okhttp
|
||||
|
||||
@@ -23,6 +23,8 @@ import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||
|
||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||
|
||||
class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
@@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
private val onComplete: () -> Unit,
|
||||
) : UrlRequest.Callback() {
|
||||
private var buffer: NativeByteBuffer? = null
|
||||
private var wrapped: ByteBuffer? = null
|
||||
private var error: Exception? = null
|
||||
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
|
||||
@@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
|
||||
try {
|
||||
// Content-Length is a size hint only. With Content-Encoding (gzip/br/...),
|
||||
// Cronet auto-decompresses and writes decompressed bytes to our buffer, which
|
||||
// may exceed the wire/compressed Content-Length. Always use the growable
|
||||
// buffer path so we can't overflow.
|
||||
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
|
||||
if (contentLength > 0) {
|
||||
buffer = NativeByteBuffer(contentLength + 1)
|
||||
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
|
||||
request.read(wrapped)
|
||||
} else {
|
||||
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
|
||||
request.read(buffer!!.wrapRemaining())
|
||||
}
|
||||
// Cap the up-front alloc: Content-Length is untrusted and can be huge or near
|
||||
// Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over.
|
||||
val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE
|
||||
buffer = NativeByteBuffer(initialSize)
|
||||
request.read(buffer!!.wrapRemaining())
|
||||
} catch (e: Exception) {
|
||||
error = e
|
||||
return request.cancel()
|
||||
@@ -263,14 +265,14 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
byteBuffer: ByteBuffer
|
||||
) {
|
||||
try {
|
||||
val buf = if (wrapped == null) {
|
||||
buffer!!.run {
|
||||
advance(byteBuffer.position())
|
||||
ensureHeadroom()
|
||||
wrapRemaining()
|
||||
}
|
||||
} else {
|
||||
wrapped
|
||||
// Always pass a fresh wrap so byteBuffer.position() represents only the
|
||||
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
|
||||
// ByteBuffer breaks advance(): Cronet's position keeps accumulating
|
||||
// across reads, which would double-count previous iterations' bytes.
|
||||
val buf = buffer!!.run {
|
||||
advance(byteBuffer.position())
|
||||
ensureHeadroom()
|
||||
wrapRemaining()
|
||||
}
|
||||
request.read(buf)
|
||||
} catch (e: Exception) {
|
||||
@@ -280,7 +282,6 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
wrapped?.let { buffer!!.advance(it.position()) }
|
||||
onSuccess(buffer!!)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package app.alextran.immich.sync
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
private const val TAG = "MediaTrashDelegate"
|
||||
|
||||
class MediaTrashDelegate(context: Context) : PluginRegistry.ActivityResultListener {
|
||||
private val ctx = context.applicationContext
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||
|
||||
companion object {
|
||||
private const val PERMISSION_REQUEST_CODE = 1001
|
||||
private const val TRASH_REQUEST_CODE = 1002
|
||||
}
|
||||
|
||||
fun hasManageMediaPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaStore.canManageMedia(ctx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
if (hasManageMediaPermission()) {
|
||||
callback(Result.success(true))
|
||||
return
|
||||
}
|
||||
|
||||
openManageMediaPermissionSettings(callback)
|
||||
}
|
||||
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
openManageMediaPermissionSettings(callback)
|
||||
}
|
||||
|
||||
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
callback(Result.success(false))
|
||||
return
|
||||
}
|
||||
|
||||
val activity = activityBinding?.activity
|
||||
if (activity == null) {
|
||||
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||
return
|
||||
}
|
||||
|
||||
pendingResult = callback
|
||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
|
||||
data = "package:${activity.packageName}".toUri()
|
||||
}
|
||||
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
|
||||
callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
|
||||
return
|
||||
}
|
||||
|
||||
val id = mediaId.toLongOrNull()
|
||||
if (id == null) {
|
||||
callback(
|
||||
Result.failure(
|
||||
FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isInTrash(id)) {
|
||||
callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
|
||||
return
|
||||
}
|
||||
|
||||
restoreUris(listOf(ContentUris.withAppendedId(contentUriForType(type.toInt()), id)), callback)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun restoreUris(uris: List<Uri>, callback: (Result<Boolean>) -> Unit) {
|
||||
if (uris.isEmpty()) {
|
||||
callback(Result.failure(FlutterError("TRASH_ERROR", "No URIs to restore", null)))
|
||||
return
|
||||
}
|
||||
|
||||
toggleTrash(uris, false, callback)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun toggleTrash(
|
||||
contentUris: List<Uri>,
|
||||
isTrashed: Boolean,
|
||||
callback: (Result<Boolean>) -> Unit
|
||||
) {
|
||||
val activity = activityBinding?.activity
|
||||
if (activity == null) {
|
||||
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, contentUris, isTrashed)
|
||||
pendingResult = callback
|
||||
activity.startIntentSenderForResult(
|
||||
pendingIntent.intentSender,
|
||||
TRASH_REQUEST_CODE,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error creating or starting trash request", e)
|
||||
callback(Result.failure(FlutterError("TRASH_ERROR", "Error creating or starting trash request", null)))
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun isInTrash(id: Long): Boolean {
|
||||
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val args = Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
|
||||
}
|
||||
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||
?.use { it.moveToFirst() } == true
|
||||
}
|
||||
|
||||
private fun contentUriForType(type: Int): Uri =
|
||||
when (type) {
|
||||
// Same order as AssetType from Dart.
|
||||
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
}
|
||||
|
||||
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
fun onDetachedFromActivity() {
|
||||
activityBinding?.removeActivityResultListener(this)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == PERMISSION_REQUEST_CODE) {
|
||||
pendingResult?.invoke(Result.success(hasManageMediaPermission()))
|
||||
pendingResult = null
|
||||
return true
|
||||
}
|
||||
|
||||
if (requestCode == TRASH_REQUEST_CODE) {
|
||||
pendingResult?.invoke(Result.success(resultCode == Activity.RESULT_OK))
|
||||
pendingResult = null
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -553,6 +553,10 @@ interface NativeSyncApi {
|
||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun cancelHashing()
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun hasManageMediaPermission(): Boolean
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
|
||||
companion object {
|
||||
@@ -747,6 +751,78 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.hasManageMediaPermission())
|
||||
} catch (exception: Throwable) {
|
||||
MessagesPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.requestManageMediaPermission{ result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.manageMediaPermission{ result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val mediaIdArg = args[0] as String
|
||||
val typeArg = args[1] as Long
|
||||
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -39,10 +41,11 @@ sealed class AssetResult {
|
||||
private const val TAG = "NativeSyncApiImplBase"
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private var hashTask: Job? = null
|
||||
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
||||
|
||||
companion object {
|
||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||
@@ -448,6 +451,36 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
hashTask = null
|
||||
}
|
||||
|
||||
fun hasManageMediaPermission(): Boolean = mediaTrashDelegate.hasManageMediaPermission()
|
||||
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
mediaTrashDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
mediaTrashDelegate.manageMediaPermission { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
mediaTrashDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
mediaTrashDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
|
||||
+3701
File diff suppressed because it is too large
Load Diff
Generated
+69
@@ -537,6 +537,10 @@ protocol NativeSyncApi {
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func hasManageMediaPermission() throws -> Bool
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
}
|
||||
|
||||
@@ -721,6 +725,71 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.hasManageMediaPermission()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasManageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
api.requestManageMediaPermission { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestManageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
manageMediaPermissionChannel.setMessageHandler {
|
||||
_, reply in
|
||||
api.manageMediaPermission {
|
||||
result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
manageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
restoreFromTrashByIdChannel.setMessageHandler {
|
||||
message, reply in
|
||||
let args = message as! [Any?]
|
||||
let mediaIdArg = args[0] as !String
|
||||
let typeArg = args[1] as !Int64
|
||||
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) {
|
||||
result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreFromTrashByIdChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
|
||||
@@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
|
||||
var domainAlbum = PlatformAlbum(
|
||||
id: album.localIdentifier,
|
||||
name: album.localizedTitle!,
|
||||
name: album.localizedTitle ?? album.localIdentifier,
|
||||
updatedAt: nil,
|
||||
isCloud: isCloud,
|
||||
assetCount: Int64(assets.count)
|
||||
@@ -382,6 +382,22 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||
}
|
||||
|
||||
func hasManageMediaPermission() throws -> Bool {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||
}
|
||||
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
|
||||
}
|
||||
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
|
||||
}
|
||||
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
|
||||
}
|
||||
|
||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
|
||||
@@ -18,3 +18,7 @@ enum CleanupStep { selectDate, scan, delete }
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
enum AssetDateAggregation { start, end }
|
||||
|
||||
enum SlideshowLook { contain, cover, blurredBackground }
|
||||
|
||||
enum SlideshowDirection { forward, backward, shuffle }
|
||||
|
||||
@@ -31,7 +31,7 @@ class LocalAsset extends BaseAsset {
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required super.isEdited,
|
||||
super.isEdited = false,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
|
||||
@@ -23,6 +23,7 @@ class RemoteAsset extends BaseAsset {
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
this.uploadedAt,
|
||||
this.deletedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationMs,
|
||||
@@ -32,7 +33,6 @@ class RemoteAsset extends BaseAsset {
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -62,6 +62,7 @@ class RemoteAsset extends BaseAsset {
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
uploadedAt: ${uploadedAt ?? "<NA>"},
|
||||
deletedAt: ${deletedAt ?? "<NA>"},
|
||||
width: ${width ?? "<NA>"},
|
||||
height: ${height ?? "<NA>"},
|
||||
durationMs: ${durationMs ?? "<NA>"},
|
||||
@@ -89,6 +90,7 @@ class RemoteAsset extends BaseAsset {
|
||||
ownerId == other.ownerId &&
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
deletedAt == other.deletedAt &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
@@ -102,6 +104,7 @@ class RemoteAsset extends BaseAsset {
|
||||
localId.hashCode ^
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
deletedAt.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
@@ -116,6 +119,7 @@ class RemoteAsset extends BaseAsset {
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
DateTime? deletedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationMs,
|
||||
@@ -125,7 +129,6 @@ class RemoteAsset extends BaseAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -137,6 +140,7 @@ class RemoteAsset extends BaseAsset {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
uploadedAt: uploadedAt ?? this.uploadedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
@@ -146,7 +150,6 @@ class RemoteAsset extends BaseAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class RemoteDeletedLocalAsset {
|
||||
final LocalAsset asset;
|
||||
final DateTime remoteDeletedAt;
|
||||
|
||||
const RemoteDeletedLocalAsset({required this.asset, required this.remoteDeletedAt});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
class AlbumConfig {
|
||||
final AlbumSortMode sortMode;
|
||||
final bool isReverse;
|
||||
final bool isGrid;
|
||||
|
||||
const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false});
|
||||
|
||||
AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig(
|
||||
sortMode: sortMode ?? this.sortMode,
|
||||
isReverse: isReverse ?? this.isReverse,
|
||||
isGrid: isGrid ?? this.isGrid,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(sortMode, isReverse, isGrid);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)';
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:immich_mobile/domain/models/config/album_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/backup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
|
||||
@@ -12,6 +15,9 @@ class AppConfig {
|
||||
final TimelineConfig timeline;
|
||||
final ImageConfig image;
|
||||
final ViewerConfig viewer;
|
||||
final SlideshowConfig slideshow;
|
||||
final AlbumConfig album;
|
||||
final BackupConfig backup;
|
||||
|
||||
const AppConfig({
|
||||
this.theme = const .new(),
|
||||
@@ -20,6 +26,9 @@ class AppConfig {
|
||||
this.timeline = const .new(),
|
||||
this.image = const .new(),
|
||||
this.viewer = const .new(),
|
||||
this.slideshow = const .new(),
|
||||
this.album = const .new(),
|
||||
this.backup = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -29,6 +38,9 @@ class AppConfig {
|
||||
TimelineConfig? timeline,
|
||||
ImageConfig? image,
|
||||
ViewerConfig? viewer,
|
||||
SlideshowConfig? slideshow,
|
||||
AlbumConfig? album,
|
||||
BackupConfig? backup,
|
||||
}) => .new(
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
@@ -36,6 +48,9 @@ class AppConfig {
|
||||
timeline: timeline ?? this.timeline,
|
||||
image: image ?? this.image,
|
||||
viewer: viewer ?? this.viewer,
|
||||
slideshow: slideshow ?? this.slideshow,
|
||||
album: album ?? this.album,
|
||||
backup: backup ?? this.backup,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -47,12 +62,15 @@ class AppConfig {
|
||||
other.map == map &&
|
||||
other.timeline == timeline &&
|
||||
other.image == image &&
|
||||
other.viewer == viewer);
|
||||
other.viewer == viewer &&
|
||||
other.slideshow == slideshow &&
|
||||
other.album == album &&
|
||||
other.backup == backup);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer);
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)';
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
class BackupConfig {
|
||||
final bool enabled;
|
||||
final bool useCellularForVideos;
|
||||
final bool useCellularForPhotos;
|
||||
final bool requireCharging;
|
||||
final int triggerDelay;
|
||||
final bool syncAlbums;
|
||||
|
||||
const BackupConfig({
|
||||
this.enabled = false,
|
||||
this.useCellularForVideos = false,
|
||||
this.useCellularForPhotos = false,
|
||||
this.requireCharging = false,
|
||||
this.triggerDelay = 30,
|
||||
this.syncAlbums = false,
|
||||
});
|
||||
|
||||
BackupConfig copyWith({
|
||||
bool? enabled,
|
||||
bool? useCellularForVideos,
|
||||
bool? useCellularForPhotos,
|
||||
bool? requireCharging,
|
||||
int? triggerDelay,
|
||||
bool? syncAlbums,
|
||||
}) => BackupConfig(
|
||||
enabled: enabled ?? this.enabled,
|
||||
useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos,
|
||||
useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos,
|
||||
requireCharging: requireCharging ?? this.requireCharging,
|
||||
triggerDelay: triggerDelay ?? this.triggerDelay,
|
||||
syncAlbums: syncAlbums ?? this.syncAlbums,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is BackupConfig &&
|
||||
other.enabled == enabled &&
|
||||
other.useCellularForVideos == useCellularForVideos &&
|
||||
other.useCellularForPhotos == useCellularForPhotos &&
|
||||
other.requireCharging == requireCharging &&
|
||||
other.triggerDelay == triggerDelay &&
|
||||
other.syncAlbums == syncAlbums);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)';
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class NetworkConfig {
|
||||
final bool autoEndpointSwitching;
|
||||
final String? preferredWifiName;
|
||||
final String? localEndpoint;
|
||||
final List<String> externalEndpointList;
|
||||
final Map<String, String> customHeaders;
|
||||
|
||||
const NetworkConfig({
|
||||
this.autoEndpointSwitching = false,
|
||||
this.preferredWifiName,
|
||||
this.localEndpoint,
|
||||
this.externalEndpointList = const [],
|
||||
this.customHeaders = const {},
|
||||
});
|
||||
|
||||
NetworkConfig copyWith({
|
||||
bool? autoEndpointSwitching,
|
||||
String? preferredWifiName,
|
||||
String? localEndpoint,
|
||||
List<String>? externalEndpointList,
|
||||
Map<String, String>? customHeaders,
|
||||
}) => NetworkConfig(
|
||||
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
|
||||
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
|
||||
localEndpoint: localEndpoint ?? this.localEndpoint,
|
||||
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
|
||||
customHeaders: customHeaders ?? this.customHeaders,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is NetworkConfig &&
|
||||
other.autoEndpointSwitching == autoEndpointSwitching &&
|
||||
other.preferredWifiName == preferredWifiName &&
|
||||
other.localEndpoint == localEndpoint &&
|
||||
listEquals(other.externalEndpointList, externalEndpointList) &&
|
||||
mapEquals(other.customHeaders, customHeaders));
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
autoEndpointSwitching,
|
||||
preferredWifiName,
|
||||
localEndpoint,
|
||||
Object.hashAll(externalEndpointList),
|
||||
Object.hashAllUnordered(customHeaders.entries.map((e) => Object.hash(e.key, e.value))),
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'NetworkConfig(autoEndpointSwitching: $autoEndpointSwitching, preferredWifiName: $preferredWifiName, localEndpoint: $localEndpoint, externalEndpointList: $externalEndpointList, customHeaders: $customHeaders)';
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
|
||||
class SlideshowConfig {
|
||||
final bool transition;
|
||||
final bool repeat;
|
||||
final int duration;
|
||||
final SlideshowLook look;
|
||||
final SlideshowDirection direction;
|
||||
|
||||
const SlideshowConfig({
|
||||
this.transition = true,
|
||||
this.repeat = true,
|
||||
this.duration = 5,
|
||||
this.look = SlideshowLook.contain,
|
||||
this.direction = SlideshowDirection.forward,
|
||||
});
|
||||
|
||||
SlideshowConfig copyWith({
|
||||
bool? transition,
|
||||
bool? repeat,
|
||||
int? duration,
|
||||
SlideshowLook? look,
|
||||
SlideshowDirection? direction,
|
||||
}) => SlideshowConfig(
|
||||
transition: transition ?? this.transition,
|
||||
repeat: repeat ?? this.repeat,
|
||||
duration: duration ?? this.duration,
|
||||
look: look ?? this.look,
|
||||
direction: direction ?? this.direction,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is SlideshowConfig &&
|
||||
other.transition == transition &&
|
||||
other.repeat == repeat &&
|
||||
other.duration == duration &&
|
||||
other.look == look &&
|
||||
other.direction == direction);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
class SystemConfig {
|
||||
final LogLevel logLevel;
|
||||
final NetworkConfig network;
|
||||
|
||||
const SystemConfig({this.logLevel = .info});
|
||||
const SystemConfig({this.logLevel = .info, this.network = const .new()});
|
||||
|
||||
SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
|
||||
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
|
||||
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
|
||||
|
||||
@override
|
||||
int get hashCode => logLevel.hashCode;
|
||||
int get hashCode => Object.hash(logLevel, network);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel)';
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
enum MetadataDomain<T extends Object> {
|
||||
appConfig<AppConfig>('config.app'),
|
||||
@@ -34,6 +35,41 @@ enum MetadataKey<T extends Object> {
|
||||
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
|
||||
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
|
||||
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
|
||||
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
|
||||
networkExternalEndpointList<List<String>>(
|
||||
.systemConfig,
|
||||
'network.externalEndpointList',
|
||||
[],
|
||||
_ListCodec(_PrimitiveCodec.string),
|
||||
),
|
||||
networkCustomHeaders<Map<String, String>>(
|
||||
.systemConfig,
|
||||
'network.customHeaders',
|
||||
{},
|
||||
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(
|
||||
.appConfig,
|
||||
'album.sortMode',
|
||||
AlbumSortMode.mostRecent,
|
||||
_EnumCodec(AlbumSortMode.values),
|
||||
),
|
||||
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
||||
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
||||
|
||||
// Backup
|
||||
backupEnabled<bool>(.appConfig, 'backup.enabled', false),
|
||||
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false),
|
||||
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false),
|
||||
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false),
|
||||
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30),
|
||||
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false),
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(
|
||||
@@ -64,7 +100,19 @@ enum MetadataKey<T extends Object> {
|
||||
),
|
||||
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false);
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
|
||||
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
|
||||
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
|
||||
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(
|
||||
.appConfig,
|
||||
'slideshow.direction',
|
||||
SlideshowDirection.forward,
|
||||
_EnumCodec(SlideshowDirection.values),
|
||||
);
|
||||
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
@@ -131,6 +179,47 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
DateTime? decode(String raw) => DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
|
||||
final _MetadataCodec<K> _keyCodec;
|
||||
final _MetadataCodec<V> _valueCodec;
|
||||
|
||||
const _MapCodec(this._keyCodec, this._valueCodec);
|
||||
|
||||
@override
|
||||
String encode(Map<K, V> value) {
|
||||
final entries = <String, String>{};
|
||||
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V>? decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return null;
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
return null;
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
if (k == null || v == null) {
|
||||
return null;
|
||||
}
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
|
||||
final _MetadataCodec<T> _elementCodec;
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
|
||||
enum Setting<T> {
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
|
||||
enableBackup<bool>(StoreKey.enableBackup, false);
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false);
|
||||
|
||||
const Setting(this.storeKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -6,36 +6,35 @@ enum StoreKey<T> {
|
||||
version<int>._(0),
|
||||
currentUser<UserDto>._(2),
|
||||
deviceId<String>._(4),
|
||||
backupRequireCharging<bool>._(7),
|
||||
backupTriggerDelay<int>._(8),
|
||||
serverUrl<String>._(10),
|
||||
accessToken<String>._(11),
|
||||
serverEndpoint<String>._(12),
|
||||
selectedAlbumSortOrder<int>._(113),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
selectedAlbumSortReverse<bool>._(123),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
customHeaders<String>._(127),
|
||||
syncAlbums<bool>._(131),
|
||||
|
||||
// Auto endpoint switching
|
||||
autoEndpointSwitching<bool>._(132),
|
||||
preferredWifiName<String>._(133),
|
||||
localEndpoint<String>._(134),
|
||||
externalEndpointList<String>._(135),
|
||||
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
albumGridView<bool>._(140),
|
||||
|
||||
// Experimental stuff
|
||||
enableBackup<bool>._(1003),
|
||||
useWifiForUploadVideos<bool>._(1004),
|
||||
useWifiForUploadPhotos<bool>._(1005),
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
reviewOutOfSyncChangesAndroid<bool>._(1014),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacyBackupRequireCharging<bool>._(7),
|
||||
legacyBackupTriggerDelay<int>._(8),
|
||||
legacySyncAlbums<bool>._(131),
|
||||
legacyEnableBackup<bool>._(1003),
|
||||
legacyUseWifiForUploadVideos<bool>._(1004),
|
||||
legacyUseWifiForUploadPhotos<bool>._(1005),
|
||||
legacySelectedAlbumSortOrder<int>._(113),
|
||||
legacySelectedAlbumSortReverse<bool>._(123),
|
||||
legacyAlbumGridView<bool>._(140),
|
||||
legacyAutoEndpointSwitching<bool>._(132),
|
||||
legacyPreferredWifiName<String>._(133),
|
||||
legacyLocalEndpoint<String>._(134),
|
||||
legacyExternalEndpointList<String>._(135),
|
||||
legacyCustomHeaders<String>._(127),
|
||||
legacyLoopVideo<bool>._(117),
|
||||
legacyLoadOriginalVideo<bool>._(136),
|
||||
legacyAutoPlayVideo<bool>._(139),
|
||||
|
||||
@@ -11,15 +11,14 @@ import 'package:immich_mobile/entities/store.entity.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';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.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/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
@@ -39,16 +38,15 @@ class BackgroundWorkerFgService {
|
||||
Future<void> saveNotificationMessage(String title, String body) =>
|
||||
_foregroundHostApi.saveNotificationMessage(title, body);
|
||||
|
||||
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
|
||||
BackgroundWorkerSettings(
|
||||
minimumDelaySeconds:
|
||||
minimumDelaySeconds ??
|
||||
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
|
||||
requiresCharging:
|
||||
requireCharging ??
|
||||
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
|
||||
),
|
||||
);
|
||||
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) {
|
||||
final backup = MetadataRepository.instance.appConfig.backup;
|
||||
return _foregroundHostApi.configure(
|
||||
BackgroundWorkerSettings(
|
||||
minimumDelaySeconds: minimumDelaySeconds ?? backup.triggerDelay,
|
||||
requiresCharging: requireCharging ?? backup.requireCharging,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disable() => _foregroundHostApi.disable();
|
||||
}
|
||||
@@ -71,7 +69,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
|
||||
bool get _isBackupEnabled => MetadataRepository.instance.appConfig.backup.enabled;
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
|
||||
@@ -4,15 +4,12 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.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/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/infrastructure/repositories/local_album.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/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -23,35 +20,29 @@ class LocalSyncService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required DriftTrashSyncRepository trashSyncRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_trashSyncRepository = trashSyncRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _syncTrashedAssets();
|
||||
} else {
|
||||
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
await _syncTrashedAssets();
|
||||
await _trashSyncRepository.syncRestoresForRevivedAssets();
|
||||
}
|
||||
await _trashSyncRepository.recheckRemoteTrashCandidates();
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
// final assets = await _localAssetRepository.getEmptyCloudIdAssets();
|
||||
@@ -60,7 +51,11 @@ class LocalSyncService {
|
||||
|
||||
if (full || await _nativeSyncApi.shouldFullSync()) {
|
||||
_log.fine("Full sync request from ${full ? "user" : "native"}");
|
||||
return await fullSync();
|
||||
await fullSync();
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
await _cleanupTrashSync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final delta = await _nativeSyncApi.getMediaChanges();
|
||||
@@ -82,13 +77,13 @@ class LocalSyncService {
|
||||
);
|
||||
|
||||
final dbAlbums = await _localAlbumRepository.getAll();
|
||||
// On Android, we need to sync all albums since it is not possible to
|
||||
// detect album deletions from the native side
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
for (final album in dbAlbums) {
|
||||
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
|
||||
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
|
||||
}
|
||||
|
||||
await _cleanupTrashSync();
|
||||
}
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
@@ -115,6 +110,13 @@ class LocalSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupTrashSync() async {
|
||||
final deleted = await _trashSyncRepository.cleanup();
|
||||
if (deleted > 0) {
|
||||
_log.fine("cleanup TrashState, deleted: $deleted");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fullSync() async {
|
||||
try {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
@@ -362,7 +364,7 @@ class LocalSyncService {
|
||||
@visibleForTesting
|
||||
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
|
||||
if (trashedAssetMap.isEmpty) {
|
||||
_log.info("syncTrashedAssets, No trashed assets found");
|
||||
_log.fine("syncTrashedAssets, No trashed assets found");
|
||||
}
|
||||
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
|
||||
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
|
||||
@@ -370,30 +372,6 @@ class LocalSyncService {
|
||||
|
||||
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
|
||||
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
|
||||
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||
}
|
||||
|
||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +416,6 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,47 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Categorizes a heterogeneous asset selection into the candidates that can
|
||||
/// be added to an album immediately (already on the server) and the local-only
|
||||
/// candidates that must be uploaded first.
|
||||
class AlbumAssetCandidates {
|
||||
final List<String> remoteAssetIds;
|
||||
final List<LocalAsset> localAssetsToUpload;
|
||||
|
||||
const AlbumAssetCandidates({required this.remoteAssetIds, required this.localAssetsToUpload});
|
||||
}
|
||||
|
||||
class RemoteAlbumService {
|
||||
static final _logger = Logger('RemoteAlbumService');
|
||||
|
||||
final DriftRemoteAlbumRepository _repository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final ForegroundUploadService _uploadService;
|
||||
|
||||
const RemoteAlbumService(this._repository, this._albumApiRepository);
|
||||
const RemoteAlbumService(this._repository, this._albumApiRepository, this._uploadService);
|
||||
|
||||
/// Categorizes a heterogeneous asset selection into already-on-server IDs
|
||||
/// and local assets that still need to be uploaded.
|
||||
static AlbumAssetCandidates categorizeCandidates(Iterable<BaseAsset> assets) {
|
||||
final remoteIds = <String>[];
|
||||
final localToUpload = <LocalAsset>[];
|
||||
for (final asset in assets) {
|
||||
if (asset is RemoteAsset) {
|
||||
remoteIds.add(asset.id);
|
||||
} else if (asset is LocalAsset) {
|
||||
final remoteId = asset.remoteId;
|
||||
if (remoteId != null) {
|
||||
remoteIds.add(remoteId);
|
||||
} else {
|
||||
localToUpload.add(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
return AlbumAssetCandidates(remoteAssetIds: remoteIds, localAssetsToUpload: localToUpload);
|
||||
}
|
||||
|
||||
Stream<RemoteAlbum?> watchAlbum(String albumId) {
|
||||
return _repository.watchAlbum(albumId);
|
||||
@@ -148,6 +183,122 @@ class RemoteAlbumService {
|
||||
return album.added.length;
|
||||
}
|
||||
|
||||
/// !TODO The name here is not clear as we have addAssets method above,
|
||||
/// which is only add remote assets to album, for the next PR, we will allow
|
||||
/// adding local assets from album from the timeline as well with this flow.
|
||||
/// So saving that for the next refactor
|
||||
Future<int> addAssetsToAlbum({
|
||||
required String albumId,
|
||||
required UserDto uploader,
|
||||
required AlbumAssetCandidates candidates,
|
||||
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
|
||||
}) async {
|
||||
int addedCount = 0;
|
||||
if (candidates.remoteAssetIds.isNotEmpty) {
|
||||
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
|
||||
}
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
addedCount += await _uploadAndAddLocals(albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks);
|
||||
}
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// Creates an album, seeding it with already-remote asset IDs, then uploads
|
||||
/// local-only assets and links each one as it finishes.
|
||||
Future<RemoteAlbum> createAlbumWithAssets({
|
||||
required String title,
|
||||
required UserDto owner,
|
||||
String? description,
|
||||
AlbumAssetCandidates candidates = const AlbumAssetCandidates(remoteAssetIds: [], localAssetsToUpload: []),
|
||||
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
|
||||
}) async {
|
||||
final album = await createAlbum(
|
||||
title: title,
|
||||
owner: owner,
|
||||
description: description,
|
||||
assetIds: candidates.remoteAssetIds,
|
||||
);
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
await _uploadAndAddLocals(album.id, owner, candidates.localAssetsToUpload, uploadCallbacks);
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
Future<int> _uploadAndAddLocals(
|
||||
String albumId,
|
||||
UserDto uploader,
|
||||
List<LocalAsset> localAssets,
|
||||
UploadCallbacks userCallbacks,
|
||||
) async {
|
||||
int addedCount = 0;
|
||||
final pendingAdds = <Future<void>>[];
|
||||
final localById = {for (final a in localAssets) a.id: a};
|
||||
|
||||
final wrappedCallbacks = UploadCallbacks(
|
||||
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
|
||||
'Upload progress callback failed for $localId',
|
||||
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
|
||||
),
|
||||
onICloudProgress: (localId, progress) => _runUploadCallback(
|
||||
'iCloud progress callback failed for $localId',
|
||||
() => userCallbacks.onICloudProgress?.call(localId, progress),
|
||||
),
|
||||
onError: (localId, errorMessage) => _runUploadCallback(
|
||||
'Upload error callback failed for $localId',
|
||||
() => userCallbacks.onError?.call(localId, errorMessage),
|
||||
),
|
||||
onSuccess: (localId, remoteId) {
|
||||
_runUploadCallback(
|
||||
'Upload success callback failed for $localId',
|
||||
() => userCallbacks.onSuccess?.call(localId, remoteId),
|
||||
);
|
||||
final source = localById[localId];
|
||||
if (source == null) {
|
||||
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
|
||||
return;
|
||||
}
|
||||
pendingAdds.add(
|
||||
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
|
||||
.then<void>((added) {
|
||||
addedCount += added;
|
||||
})
|
||||
.catchError((Object error, StackTrace stack) {
|
||||
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
|
||||
await Future.wait(pendingAdds);
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
void _runUploadCallback(String message, void Function() callback) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error, stack) {
|
||||
_logger.warning(message, error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// Links a freshly-uploaded asset to an album, ensuring the local DB
|
||||
/// reflects the change without waiting for the next sync. We call the API
|
||||
/// (server is the source of truth), then upsert a placeholder
|
||||
/// `remote_asset_entity` row from the local source so the FK-protected
|
||||
/// junction insert succeeds. Sync overwrites the placeholder later with
|
||||
/// the authoritative server data.
|
||||
Future<int> _linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async {
|
||||
final result = await _albumApiRepository.addAssets(albumId, [remoteId]);
|
||||
if (result.added.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await _repository.upsertRemoteAssetStub(remoteId: remoteId, ownerId: uploader.id, source: source);
|
||||
await _repository.addAssets(albumId, result.added);
|
||||
return result.added.length;
|
||||
}
|
||||
|
||||
Future<void> deleteAlbum(String albumId) async {
|
||||
await _albumApiRepository.deleteAlbum(albumId);
|
||||
|
||||
|
||||
@@ -3,18 +3,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -27,15 +22,14 @@ enum SyncMigrationTask {
|
||||
v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column.
|
||||
}
|
||||
|
||||
typedef _RemoteAssetTrashState = ({String id, DateTime? deletedAt, String? checksum});
|
||||
|
||||
class SyncStreamService {
|
||||
final Logger _logger = Logger('SyncStreamService');
|
||||
|
||||
final SyncApiRepository _syncApiRepository;
|
||||
final SyncStreamRepository _syncStreamRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final SyncMigrationRepository _syncMigrationRepository;
|
||||
final ApiService _api;
|
||||
final bool Function()? _cancelChecker;
|
||||
@@ -43,19 +37,13 @@ class SyncStreamService {
|
||||
SyncStreamService({
|
||||
required SyncApiRepository syncApiRepository,
|
||||
required SyncStreamRepository syncStreamRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required DriftTrashSyncRepository trashSyncRepository,
|
||||
required SyncMigrationRepository syncMigrationRepository,
|
||||
required ApiService api,
|
||||
bool Function()? cancelChecker,
|
||||
}) : _syncApiRepository = syncApiRepository,
|
||||
_syncStreamRepository = syncStreamRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_trashSyncRepository = trashSyncRepository,
|
||||
_syncMigrationRepository = syncMigrationRepository,
|
||||
_api = api,
|
||||
_cancelChecker = cancelChecker;
|
||||
@@ -200,22 +188,24 @@ class SyncStreamService {
|
||||
case SyncEntityType.assetV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
await _handleRemoteAssetTrashState(
|
||||
remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
|
||||
);
|
||||
return;
|
||||
case SyncEntityType.assetV2:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV2>();
|
||||
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
await _handleRemoteAssetTrashState(
|
||||
remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
|
||||
);
|
||||
return;
|
||||
case SyncEntityType.assetDeleteV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final remoteDeletedAtByRemoteId = Map<String, DateTime>.fromEntries(
|
||||
remoteSyncAssets.map((e) => MapEntry(e.assetId, now)),
|
||||
);
|
||||
await _trashSyncRepository.recordRemoteTrash(remoteDeletedAtByRemoteId);
|
||||
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
|
||||
case SyncEntityType.assetExifV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||
@@ -486,58 +476,17 @@ class SyncStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return Future.value();
|
||||
} else {
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
await _trashLocalAssets(localAssetsToTrash);
|
||||
} else {
|
||||
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
|
||||
Future<void> _handleRemoteAssetTrashState(Iterable<_RemoteAssetTrashState> remoteSyncAssets) async {
|
||||
final deleted = <String, DateTime>{};
|
||||
final aliveChecksums = <String>[];
|
||||
for (final e in remoteSyncAssets) {
|
||||
if (e.deletedAt != null) {
|
||||
deleted[e.id] = e.deletedAt!;
|
||||
} else if (e.checksum != null) {
|
||||
aliveChecksums.add(e.checksum!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteRestoreToLocal() async {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_logger.info("No remote assets found for restoration");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
await _applyRemoteRestoreToLocal();
|
||||
}
|
||||
|
||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
await _trashSyncRepository.recordRemoteTrash(deleted);
|
||||
await _trashSyncRepository.recordRemoteRestore(aliveChecksums);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
|
||||
|
||||
final tagServiceProvider = Provider<TagService>((ref) => TagService(ref.watch(tagsApiRepositoryProvider)));
|
||||
|
||||
class TagService {
|
||||
final TagsApiRepository _repository;
|
||||
|
||||
const TagService(this._repository);
|
||||
|
||||
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
|
||||
return _repository.bulkTagAssets(assetIds, tagIds);
|
||||
}
|
||||
|
||||
Future<Set<Tag>> getAllTags() async {
|
||||
final dtos = await _repository.getAllTags();
|
||||
if (dtos == null) {
|
||||
return {};
|
||||
}
|
||||
return dtos.map((dto) => Tag.fromDto(dto)).toSet();
|
||||
}
|
||||
|
||||
Future<List<Tag>> upsertTags(List<String> tags) async {
|
||||
final dtos = await _repository.upsertTags(tags);
|
||||
if (dtos == null) {
|
||||
return [];
|
||||
}
|
||||
return dtos.map((dto) => Tag.fromDto(dto)).toList();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ enum TimelineOrigin {
|
||||
albumActivities,
|
||||
folder,
|
||||
recentlyAdded,
|
||||
syncTrash,
|
||||
}
|
||||
|
||||
class TimelineFactory {
|
||||
@@ -69,6 +70,8 @@ class TimelineFactory {
|
||||
|
||||
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));
|
||||
|
||||
TimelineService toTrashSyncReview() => TimelineService(_timelineRepository.toTrashSyncReview(groupBy));
|
||||
|
||||
TimelineService archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
|
||||
|
||||
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));
|
||||
|
||||
@@ -4,6 +4,8 @@ extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
|
||||
}
|
||||
|
||||
String? get nullIfEmpty => isEmpty ? null : this;
|
||||
}
|
||||
|
||||
extension DurationExtension on String {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
|
||||
enum TrashStateDecision {
|
||||
// do not change this order!
|
||||
pendingReview,
|
||||
kept,
|
||||
appTrashed,
|
||||
}
|
||||
|
||||
enum TrashTriggerSource {
|
||||
// do not change this order!
|
||||
remoteSync,
|
||||
localUser,
|
||||
}
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_decision ON trash_sync_entity (decision)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync_entity (checksum)')
|
||||
class TrashSyncEntity extends LocalAssetEntity {
|
||||
const TrashSyncEntity();
|
||||
|
||||
IntColumn get decision => intEnum<TrashStateDecision>()();
|
||||
|
||||
IntColumn get triggerSource => intEnum<TrashTriggerSource>()();
|
||||
|
||||
DateTimeColumn get remoteDeletedAt => dateTime().nullable()();
|
||||
|
||||
DateTimeColumn get decidedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
extension TrashSyncEntityDataDomainExtension on TrashSyncEntityData {
|
||||
LocalAsset toLocalAsset() => LocalAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
checksum: checksum,
|
||||
type: type,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
durationMs: durationMs,
|
||||
isFavorite: isFavorite,
|
||||
height: height,
|
||||
width: width,
|
||||
orientation: orientation,
|
||||
playbackStyle: playbackStyle,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
cloudId: iCloudId,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
enum TrashOrigin {
|
||||
// do not change this order!
|
||||
@@ -13,23 +12,13 @@ enum TrashOrigin {
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)')
|
||||
class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
class TrashedLocalAssetEntity extends LocalAssetEntity {
|
||||
const TrashedLocalAssetEntity();
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
TextColumn get albumId => text()();
|
||||
|
||||
TextColumn get checksum => text().nullable()();
|
||||
|
||||
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
|
||||
|
||||
IntColumn get orientation => integer().withDefault(const Constant(0))();
|
||||
|
||||
IntColumn get source => intEnum<TrashOrigin>()();
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id, albumId};
|
||||
}
|
||||
|
||||
+633
-364
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||
@@ -25,6 +24,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
@@ -32,10 +32,6 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
@@ -61,13 +57,13 @@ import 'package:sqlite_async/sqlite_async.dart';
|
||||
TrashedLocalAssetEntity,
|
||||
AssetEditEntity,
|
||||
MetadataEntity,
|
||||
TrashSyncEntity,
|
||||
],
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
class Drift extends $Drift {
|
||||
Drift(super.executor);
|
||||
|
||||
Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
|
||||
Drift([QueryExecutor? executor])
|
||||
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
|
||||
|
||||
Future<void> reset() async {
|
||||
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
|
||||
@@ -104,7 +100,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 26;
|
||||
int get schemaVersion => 27;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -282,6 +278,16 @@ class Drift extends $Drift {
|
||||
from25To26: (m, v26) async {
|
||||
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
|
||||
},
|
||||
from26To27: (m, v27) async {
|
||||
await m.create(v27.trashSyncEntity);
|
||||
await m.createIndex(v27.idxTrashSyncDecision);
|
||||
await m.createIndex(v27.idxTrashSyncChecksum);
|
||||
|
||||
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.iCloudId);
|
||||
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.adjustmentTime);
|
||||
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.latitude);
|
||||
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.longitude);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -311,18 +317,3 @@ class DriftDatabaseRepository {
|
||||
|
||||
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
|
||||
}
|
||||
|
||||
Future<SqliteConnection> openSqliteConnection({required String name}) async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, '$name.sqlite'));
|
||||
return SqliteDatabase(path: file.path);
|
||||
}
|
||||
|
||||
Future<void> configureSqliteCache() async {
|
||||
// Make sqlite3 pick a more suitable location for temporary files - the
|
||||
// one from the system may be inaccessible due to sand-boxing.
|
||||
final cacheBase = (await getTemporaryDirectory()).path;
|
||||
// We can't access /tmp on Android, which sqlite3 would try by default.
|
||||
// Explicitly tell it about the correct temporary directory.
|
||||
sqlite3.tempDirectory = cacheBase;
|
||||
}
|
||||
|
||||
+13
-4
@@ -45,9 +45,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
|
||||
as i21;
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
|
||||
as i22;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
|
||||
as i23;
|
||||
import 'package:drift/internal/modular.dart' as i24;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i24;
|
||||
import 'package:drift/internal/modular.dart' as i25;
|
||||
|
||||
abstract class $Drift extends i0.GeneratedDatabase {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -94,9 +96,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
|
||||
this,
|
||||
);
|
||||
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
|
||||
late final i23.$TrashSyncEntityTable trashSyncEntity = i23
|
||||
.$TrashSyncEntityTable(this);
|
||||
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
|
||||
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -133,6 +137,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
metadataEntity,
|
||||
trashSyncEntity,
|
||||
i10.idxPartnerSharedWithId,
|
||||
i11.idxLatLng,
|
||||
i11.idxRemoteExifCity,
|
||||
@@ -145,6 +150,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i20.idxTrashedLocalAssetChecksum,
|
||||
i20.idxTrashedLocalAssetAlbum,
|
||||
i21.idxAssetEditAssetId,
|
||||
i23.idxTrashSyncDecision,
|
||||
i23.idxTrashSyncChecksum,
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules
|
||||
@@ -397,4 +404,6 @@ class $DriftManager {
|
||||
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i22.$$MetadataEntityTableTableManager get metadataEntity =>
|
||||
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
|
||||
i23.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
|
||||
i23.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
|
||||
}
|
||||
|
||||
@@ -13539,6 +13539,714 @@ i1.GeneratedColumn<String> _column_212(String aliasedName) =>
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
|
||||
final class Schema27 extends i0.VersionedSchema {
|
||||
Schema27({required super.database}) : super(version: 27);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
metadata,
|
||||
trashSyncEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
idxTrashSyncDecision,
|
||||
idxTrashSyncChecksum,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape36 localAssetEntity = Shape36(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||
'idx_remote_asset_owner_visibility_deleted_created',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_159, _column_177],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 trashedLocalAssetEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_205,
|
||||
_column_206,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 metadata = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'metadata',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_211, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape52 trashSyncEntity = Shape52(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trash_sync_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
_column_216,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteExifCity = i1.Index(
|
||||
'idx_remote_exif_city',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
||||
'idx_asset_face_visible_person',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxTrashSyncDecision = i1.Index(
|
||||
'idx_trash_sync_decision',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trash_sync_decision ON trash_sync_entity (decision)',
|
||||
);
|
||||
final i1.Index idxTrashSyncChecksum = i1.Index(
|
||||
'idx_trash_sync_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync_entity (checksum)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape51 extends i0.VersionedTable {
|
||||
Shape51({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get iCloudId =>
|
||||
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationMs =>
|
||||
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get albumId =>
|
||||
columnsByName['album_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get source =>
|
||||
columnsByName['source']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
class Shape52 extends i0.VersionedTable {
|
||||
Shape52({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get iCloudId =>
|
||||
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationMs =>
|
||||
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get decision =>
|
||||
columnsByName['decision']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get triggerSource =>
|
||||
columnsByName['trigger_source']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get remoteDeletedAt =>
|
||||
columnsByName['remote_deleted_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get decidedAt =>
|
||||
columnsByName['decided_at']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_213(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'decision',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_214(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'trigger_source',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_215(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'remote_deleted_at',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_216(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'decided_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||
defaultValue: const i1.CustomExpression('CURRENT_TIMESTAMP'),
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -13565,6 +14273,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -13693,6 +14402,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from25To26(migrator, schema);
|
||||
return 26;
|
||||
case 26:
|
||||
final schema = Schema27(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from26To27(migrator, schema);
|
||||
return 27;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -13725,6 +14439,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -13752,5 +14467,6 @@ i1.OnUpgrade stepByStep({
|
||||
from23To24: from23To24,
|
||||
from24To25: from24To25,
|
||||
from25To26: from25To26,
|
||||
from26To27: from26To27,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/domain/models/asset/remote_deleted_local_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
@@ -109,41 +110,70 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return query.map((localAlbum) => localAlbum.toDto()).get();
|
||||
}
|
||||
|
||||
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return {};
|
||||
Future<List<RemoteDeletedLocalAsset>> getRemoteTrashCandidates(
|
||||
Map<String, DateTime> remoteDeletedAtByRemoteId,
|
||||
) async {
|
||||
if (remoteDeletedAtByRemoteId.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final result = <String, List<LocalAsset>>{};
|
||||
|
||||
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
|
||||
final rows =
|
||||
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
])..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.remoteAssetEntity.id.isIn(slice),
|
||||
))
|
||||
.get();
|
||||
|
||||
final byLocalId = <String, RemoteDeletedLocalAsset>{};
|
||||
for (final slice in remoteDeletedAtByRemoteId.keys.toSet().slices(kDriftMaxChunk)) {
|
||||
final rows = await _remoteTrashCandidatesQuery(slice).get();
|
||||
for (final row in rows) {
|
||||
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
|
||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||
(result[albumId] ??= <LocalAsset>[]).add(asset);
|
||||
final assetData = row.readTable(_db.localAssetEntity);
|
||||
final remoteId = row.read(_db.remoteAssetEntity.id)!;
|
||||
byLocalId.putIfAbsent(
|
||||
assetData.id,
|
||||
() => RemoteDeletedLocalAsset(
|
||||
asset: assetData.toDto(remoteId: remoteId),
|
||||
remoteDeletedAt: remoteDeletedAtByRemoteId[remoteId]!,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return byLocalId.values.toList();
|
||||
}
|
||||
|
||||
JoinedSelectStatement<HasResultSet, dynamic> _remoteTrashCandidatesQuery(List<String> remoteIdSlice) {
|
||||
return _db.select(_db.localAssetEntity).join([
|
||||
innerJoin(
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.trashSyncEntity,
|
||||
_db.localAssetEntity.id.equalsExp(_db.trashSyncEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..addColumns([_db.remoteAssetEntity.id])
|
||||
..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.remoteAssetEntity.id.isIn(remoteIdSlice) &
|
||||
_db.trashSyncEntity.id.isNull(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, DateTime>> getRemotelyDeletedRemoteIds() async {
|
||||
final rows =
|
||||
await (_db.selectOnly(_db.remoteAssetEntity)
|
||||
..addColumns([_db.remoteAssetEntity.id, _db.remoteAssetEntity.deletedAt])
|
||||
..where(_db.remoteAssetEntity.deletedAt.isNotNull()))
|
||||
.get();
|
||||
return {for (final r in rows) r.read(_db.remoteAssetEntity.id)!: r.read(_db.remoteAssetEntity.deletedAt)!};
|
||||
}
|
||||
|
||||
Future<RemovalCandidatesResult> getRemovalCandidates(
|
||||
@@ -214,6 +244,20 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getByIds(Iterable<String> ids) async {
|
||||
final assetIds = ids.toSet();
|
||||
if (assetIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
final assets = <LocalAsset>[];
|
||||
for (final slice in assetIds.slices(kDriftMaxChunk)) {
|
||||
final query = _db.localAssetEntity.select()..where((row) => row.id.isIn(slice));
|
||||
final rows = await query.map((row) => row.toDto()).get();
|
||||
assets.addAll(rows);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
Future<void> reconcileHashesFromCloudId() async {
|
||||
await _db.customUpdate(
|
||||
'''
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(tables: [LogMessageEntity])
|
||||
class DriftLogger extends $DriftLogger {
|
||||
DriftLogger.fromExecutor(super.executor);
|
||||
|
||||
DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
|
||||
DriftLogger([QueryExecutor? executor])
|
||||
: super(
|
||||
executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)),
|
||||
);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
@@ -19,8 +19,7 @@ class DriftLogger extends $DriftLogger {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
await customStatement('PRAGMA synchronous = NORMAL');
|
||||
await customStatement('PRAGMA journal_mode = WAL');
|
||||
await customStatement('PRAGMA busy_timeout = 30000'); // 30s
|
||||
await customStatement('PRAGMA cache_size = -32000'); // 32MB
|
||||
await customStatement('PRAGMA busy_timeout = 500');
|
||||
await customStatement('PRAGMA temp_store = MEMORY');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
@@ -139,9 +140,38 @@ extension<T extends Object> on MetadataDomain<T> {
|
||||
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
|
||||
tapToNavigate: repo._read(.viewerTapToNavigate),
|
||||
),
|
||||
slideshow: .new(
|
||||
transition: repo._read(.slideshowTransition),
|
||||
repeat: repo._read(.slideshowRepeat),
|
||||
duration: repo._read(.slideshowDuration),
|
||||
look: repo._read(.slideshowLook),
|
||||
direction: repo._read(.slideshowDirection),
|
||||
),
|
||||
album: .new(
|
||||
sortMode: repo._read(.albumSortMode),
|
||||
isReverse: repo._read(.albumIsReverse),
|
||||
isGrid: repo._read(.albumIsGrid),
|
||||
),
|
||||
backup: .new(
|
||||
enabled: repo._read(.backupEnabled),
|
||||
useCellularForVideos: repo._read(.backupUseCellularForVideos),
|
||||
useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
|
||||
requireCharging: repo._read(.backupRequireCharging),
|
||||
triggerDelay: repo._read(.backupTriggerDelay),
|
||||
syncAlbums: repo._read(.backupSyncAlbums),
|
||||
),
|
||||
);
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
|
||||
repo._systemConfig = .new(
|
||||
logLevel: repo._read(.logLevel),
|
||||
network: .new(
|
||||
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
|
||||
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
|
||||
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
|
||||
externalEndpointList: repo._read(.networkExternalEndpointList),
|
||||
customHeaders: repo._read(.networkCustomHeaders),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
enum SortRemoteAlbumsBy { id, updatedAt }
|
||||
@@ -159,7 +160,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
createdAt: Value(album.createdAt),
|
||||
updatedAt: Value(album.updatedAt),
|
||||
description: Value(album.description),
|
||||
thumbnailAssetId: Value(album.thumbnailAssetId),
|
||||
thumbnailAssetId: Value(album.thumbnailAssetId ?? (assetIds.isNotEmpty ? assetIds.first : null)),
|
||||
isActivityEnabled: Value(album.isActivityEnabled),
|
||||
order: Value(album.order),
|
||||
);
|
||||
@@ -274,17 +275,59 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
Future<int> addAssets(String albumId, List<String> assetIds) async {
|
||||
if (assetIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final albumAssets = assetIds.map(
|
||||
(assetId) => RemoteAlbumAssetEntityCompanion(albumId: Value(albumId), assetId: Value(assetId)),
|
||||
);
|
||||
|
||||
await _db.batch((batch) {
|
||||
batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets);
|
||||
await _db.transaction(() async {
|
||||
await _db.batch((batch) {
|
||||
batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets);
|
||||
});
|
||||
|
||||
final album = _db.update(_db.remoteAlbumEntity)
|
||||
..where((row) => row.id.equals(albumId) & row.thumbnailAssetId.isNull());
|
||||
|
||||
await album.write(RemoteAlbumEntityCompanion(thumbnailAssetId: Value(assetIds.first)));
|
||||
});
|
||||
|
||||
return assetIds.length;
|
||||
}
|
||||
|
||||
/// Inserts a placeholder `remote_asset_entity` row from a freshly-uploaded
|
||||
/// local asset. Skips silently if a row with the same id or
|
||||
/// (owner_id, checksum) already exists — sync will overwrite with the
|
||||
/// authoritative server data once the AssetUploadReadyV1 event is processed.
|
||||
Future<void> upsertRemoteAssetStub({
|
||||
required String remoteId,
|
||||
required String ownerId,
|
||||
required LocalAsset source,
|
||||
}) async {
|
||||
await _db
|
||||
.into(_db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion(
|
||||
id: Value(remoteId),
|
||||
ownerId: Value(ownerId),
|
||||
checksum: Value(source.checksum ?? remoteId),
|
||||
name: Value(source.name),
|
||||
type: Value(source.type),
|
||||
createdAt: Value(source.createdAt),
|
||||
updatedAt: Value(source.updatedAt),
|
||||
width: Value(source.width),
|
||||
height: Value(source.height),
|
||||
durationMs: Value(source.durationMs),
|
||||
isFavorite: Value(source.isFavorite),
|
||||
visibility: const Value(AssetVisibility.timeline),
|
||||
isEdited: Value(source.isEdited),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addUsers(String albumId, List<String> userIds) {
|
||||
final albumUsers = userIds.map(
|
||||
(assetId) => RemoteAlbumUserEntityCompanion(
|
||||
|
||||
@@ -14,4 +14,13 @@ class TagsApiRepository extends ApiRepository {
|
||||
Future<List<TagResponseDto>?> getAllTags() async {
|
||||
return await _api.getAllTags();
|
||||
}
|
||||
|
||||
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
|
||||
final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds));
|
||||
return response?.count ?? 0;
|
||||
}
|
||||
|
||||
Future<List<TagResponseDto>?> upsertTags(List<String> tags) async {
|
||||
return _api.upsertTags(TagUpsertDto(tags: tags));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import 'package:drift/drift.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.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/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
@@ -346,6 +348,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
joinLocal: true,
|
||||
);
|
||||
|
||||
TimelineQuery toTrashSyncReview(GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchTrashSyncBucket(groupBy: groupBy),
|
||||
assetSource: (offset, count) => _getToTrashSyncBucketAssets(offset: offset, count: count),
|
||||
origin: TimelineOrigin.syncTrash,
|
||||
);
|
||||
|
||||
TimelineQuery archived(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
||||
filter: (row) =>
|
||||
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
|
||||
@@ -678,6 +686,58 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> _watchTrashSyncBucket({GroupAssetsBy groupBy = GroupAssetsBy.day}) {
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
throw UnsupportedError("GroupAssetsBy.none is not supported for watchTrashSyncBucket");
|
||||
}
|
||||
|
||||
final assetCountExp = _db.localAssetEntity.id.count();
|
||||
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy, toLocal: true);
|
||||
|
||||
final query = _db.localAssetEntity.selectOnly()
|
||||
..addColumns([assetCountExp, dateExp])
|
||||
..where(_db.localAssetEntity.id.isInQuery(_pendingTrashSyncIdsSubquery()))
|
||||
..groupBy([dateExp])
|
||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||
|
||||
return query.map((row) {
|
||||
final timeline = row.read(dateExp)!.truncateDate(groupBy);
|
||||
final assetCount = row.read(assetCountExp)!;
|
||||
return TimeBucket(date: timeline, assetCount: assetCount);
|
||||
}).watch();
|
||||
}
|
||||
|
||||
Future<List<BaseAsset>> _getToTrashSyncBucketAssets({required int offset, required int count}) {
|
||||
final query = _db.localAssetEntity.select()
|
||||
..where((row) => row.id.isInQuery(_pendingTrashSyncIdsSubquery()))
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt), (row) => OrderingTerm.asc(row.id)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
JoinedSelectStatement<HasResultSet, dynamic> _pendingTrashSyncIdsSubquery() {
|
||||
final selectedAlbumAssets =
|
||||
_db.localAlbumAssetEntity.selectOnly().join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.trashSyncEntity.id) &
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
|
||||
);
|
||||
|
||||
return _db.trashSyncEntity.selectOnly()
|
||||
..addColumns([_db.trashSyncEntity.id])
|
||||
..where(
|
||||
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) & existsQuery(selectedAlbumAssets),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Bucket> _generateBuckets(int count) {
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.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/domain/models/asset/remote_deleted_local_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/infrastructure/entities/trash_sync.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
enum TrashSyncMode { off, autoSync, review }
|
||||
|
||||
typedef RemoteTrashResolveResult = ({int displayCount, bool success});
|
||||
|
||||
class TrashSyncCandidate {
|
||||
final String localAssetId;
|
||||
final String? checksum;
|
||||
final DateTime? remoteDeletedAt;
|
||||
final TrashTriggerSource triggerSource;
|
||||
final String name;
|
||||
final AssetType type;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? durationMs;
|
||||
final bool isFavorite;
|
||||
final int orientation;
|
||||
final AssetPlaybackStyle playbackStyle;
|
||||
|
||||
const TrashSyncCandidate({
|
||||
required this.localAssetId,
|
||||
required this.checksum,
|
||||
required this.remoteDeletedAt,
|
||||
required this.triggerSource,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.durationMs,
|
||||
required this.isFavorite,
|
||||
required this.orientation,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
class DriftTrashSyncRepository extends DriftDatabaseRepository {
|
||||
final Logger _logger = Logger('DriftTrashSyncRepository');
|
||||
|
||||
final Drift _db;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
|
||||
DriftTrashSyncRepository(this._db, this._localAssetRepository, this._assetMediaRepository) : super(_db);
|
||||
|
||||
TrashSyncMode get mode {
|
||||
if (Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false)) {
|
||||
return TrashSyncMode.review;
|
||||
}
|
||||
if (Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
return TrashSyncMode.autoSync;
|
||||
}
|
||||
return TrashSyncMode.off;
|
||||
}
|
||||
|
||||
Future<void> recordRemoteTrash(Map<String, DateTime> remoteDeletedAtByRemoteId) async {
|
||||
if (remoteDeletedAtByRemoteId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final currentMode = mode;
|
||||
if (currentMode == TrashSyncMode.off) {
|
||||
return;
|
||||
}
|
||||
|
||||
final candidates = await _localAssetRepository.getRemoteTrashCandidates(remoteDeletedAtByRemoteId);
|
||||
if (candidates.isEmpty) {
|
||||
_logger.fine('No local assets matched remote-delete batch of ${remoteDeletedAtByRemoteId.length}');
|
||||
return;
|
||||
}
|
||||
|
||||
final newCandidates = candidates.map(_candidateFrom).toList();
|
||||
|
||||
if (currentMode == TrashSyncMode.autoSync && await _canMoveLocalMediaToTrash()) {
|
||||
final ids = candidates.map((c) => c.asset.id).toList();
|
||||
_logger.info('Auto-trashing ${ids.length} local assets');
|
||||
final movedIds = (await _assetMediaRepository.deleteAll(ids)).toSet();
|
||||
|
||||
await upsertCandidates(newCandidates);
|
||||
if (movedIds.isNotEmpty) {
|
||||
await markDecision(movedIds, TrashStateDecision.appTrashed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertCandidates(newCandidates);
|
||||
}
|
||||
|
||||
Future<void> recheckRemoteTrashCandidates() async {
|
||||
if (mode == TrashSyncMode.off) {
|
||||
return;
|
||||
}
|
||||
final deleted = await _localAssetRepository.getRemotelyDeletedRemoteIds();
|
||||
if (deleted.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await recordRemoteTrash(deleted);
|
||||
}
|
||||
|
||||
Future<void> recordRemoteRestore(Iterable<String> aliveRemoteChecksums) async {
|
||||
final affected = await deleteForRestoredRemotes(aliveRemoteChecksums);
|
||||
if (affected.isEmpty || mode != TrashSyncMode.autoSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
final wereAppTrashed = affected.where((r) => r.decision == TrashStateDecision.appTrashed).toList();
|
||||
if (wereAppTrashed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CurrentPlatform.isAndroid || !await _hasManageMediaPermission('restore from trash')) {
|
||||
return;
|
||||
}
|
||||
|
||||
final localAssets = wereAppTrashed.map((r) => r.toLocalAsset()).toList();
|
||||
await _assetMediaRepository.restoreAssetsFromTrash(localAssets);
|
||||
}
|
||||
|
||||
Future<void> syncRestoresForRevivedAssets() async {
|
||||
if (mode != TrashSyncMode.autoSync) {
|
||||
return;
|
||||
}
|
||||
if (!CurrentPlatform.isAndroid) {
|
||||
return;
|
||||
}
|
||||
if (!await _hasManageMediaPermission('restore from trash')) {
|
||||
return;
|
||||
}
|
||||
|
||||
final rows = await getAppTrashedRemotelyRestored();
|
||||
if (rows.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final localAssets = rows.map((r) => r.toLocalAsset()).toList();
|
||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(localAssets);
|
||||
if (restoredIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteByAssetIds(restoredIds);
|
||||
}
|
||||
|
||||
Future<RemoteTrashResolveResult> applyReviewDecision(Iterable<String> localAssetIds, {required bool keep}) async {
|
||||
final ids = localAssetIds.toSet();
|
||||
if (ids.isEmpty) {
|
||||
return (displayCount: 0, success: true);
|
||||
}
|
||||
|
||||
if (keep) {
|
||||
await markDecision(ids, TrashStateDecision.kept);
|
||||
return (displayCount: ids.length, success: true);
|
||||
}
|
||||
|
||||
final movedIds = (await _assetMediaRepository.deleteAll(ids.toList())).toSet();
|
||||
if (movedIds.isEmpty) {
|
||||
return (displayCount: 0, success: false);
|
||||
}
|
||||
await markDecision(movedIds, TrashStateDecision.appTrashed);
|
||||
return (displayCount: movedIds.length, success: movedIds.length == ids.length);
|
||||
}
|
||||
|
||||
Future<void> recordUserManualTrash(Iterable<String> localAssetIds) async {
|
||||
final ids = localAssetIds.toSet();
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final snapshots = await _localAssetRepository.getByIds(ids);
|
||||
if (snapshots.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final manualCandidates = snapshots
|
||||
.map(
|
||||
(a) => TrashSyncCandidate(
|
||||
localAssetId: a.id,
|
||||
checksum: a.checksum,
|
||||
remoteDeletedAt: null,
|
||||
triggerSource: TrashTriggerSource.localUser,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
createdAt: a.createdAt,
|
||||
updatedAt: a.updatedAt,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
durationMs: a.durationMs,
|
||||
isFavorite: a.isFavorite,
|
||||
orientation: a.orientation,
|
||||
playbackStyle: a.playbackStyle,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
await upsertCandidates(manualCandidates);
|
||||
await markDecision(ids, TrashStateDecision.appTrashed);
|
||||
}
|
||||
|
||||
TrashSyncCandidate _candidateFrom(RemoteDeletedLocalAsset candidate) {
|
||||
final asset = candidate.asset;
|
||||
return TrashSyncCandidate(
|
||||
localAssetId: asset.id,
|
||||
checksum: asset.checksum,
|
||||
remoteDeletedAt: candidate.remoteDeletedAt,
|
||||
triggerSource: TrashTriggerSource.remoteSync,
|
||||
name: asset.name,
|
||||
type: asset.type,
|
||||
createdAt: asset.createdAt,
|
||||
updatedAt: asset.updatedAt,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
durationMs: asset.durationMs,
|
||||
isFavorite: asset.isFavorite,
|
||||
orientation: asset.orientation,
|
||||
playbackStyle: asset.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _canMoveLocalMediaToTrash() async {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
return await _hasManageMediaPermission('move to trash');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _hasManageMediaPermission(String logContext) async {
|
||||
if (!CurrentPlatform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
final hasPermission = await _assetMediaRepository.hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
_logger.warning('$logContext blocked: MANAGE_MEDIA permission missing');
|
||||
}
|
||||
return hasPermission;
|
||||
}
|
||||
|
||||
Future<void> upsertCandidates(Iterable<TrashSyncCandidate> candidates) async {
|
||||
if (candidates.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
return _db.batch((batch) {
|
||||
for (final c in candidates) {
|
||||
batch.insert(
|
||||
_db.trashSyncEntity,
|
||||
TrashSyncEntityCompanion.insert(
|
||||
id: c.localAssetId,
|
||||
checksum: Value(c.checksum),
|
||||
decision: TrashStateDecision.pendingReview,
|
||||
triggerSource: c.triggerSource,
|
||||
remoteDeletedAt: Value(c.remoteDeletedAt),
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
createdAt: Value(c.createdAt),
|
||||
updatedAt: Value(c.updatedAt),
|
||||
width: Value(c.width),
|
||||
height: Value(c.height),
|
||||
durationMs: Value(c.durationMs),
|
||||
isFavorite: Value(c.isFavorite),
|
||||
orientation: Value(c.orientation),
|
||||
playbackStyle: Value(c.playbackStyle),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> markDecision(Iterable<String> localAssetIds, TrashStateDecision decision) {
|
||||
assert(decision != TrashStateDecision.pendingReview, 'Use upsertCandidates for pending rows');
|
||||
final ids = localAssetIds.toSet();
|
||||
if (ids.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return _db.batch((batch) {
|
||||
for (final slice in ids.slices(kDriftMaxChunk)) {
|
||||
batch.update(
|
||||
_db.trashSyncEntity,
|
||||
TrashSyncEntityCompanion(decision: Value(decision), decidedAt: Value(DateTime.now())),
|
||||
where: (tbl) => tbl.id.isIn(slice),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<TrashSyncEntityData>> deleteForRestoredRemotes(Iterable<String> remoteAliveChecksums) {
|
||||
final checksums = remoteAliveChecksums.toSet();
|
||||
if (checksums.isEmpty) {
|
||||
return Future.value(const []);
|
||||
}
|
||||
|
||||
return _db.transaction(() async {
|
||||
final affected = <TrashSyncEntityData>[];
|
||||
for (final slice in checksums.slices(kDriftMaxChunk)) {
|
||||
final rows = await (_db.select(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.checksum.isIn(slice) & t.triggerSource.equalsValue(TrashTriggerSource.remoteSync))).get();
|
||||
affected.addAll(rows);
|
||||
}
|
||||
for (final slice in checksums.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.checksum.isIn(slice) & t.triggerSource.equalsValue(TrashTriggerSource.remoteSync))).go();
|
||||
}
|
||||
return affected;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteByAssetIds(Iterable<String> localAssetIds) {
|
||||
final ids = localAssetIds.toSet();
|
||||
if (ids.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return _db.batch((batch) {
|
||||
for (final slice in ids.slices(kDriftMaxChunk)) {
|
||||
batch.deleteWhere(_db.trashSyncEntity, (t) => t.id.isIn(slice));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<TrashSyncEntityData>> getAppTrashedRemotelyRestored() async {
|
||||
final selectedTrashedAlbums =
|
||||
_db.trashedLocalAssetEntity.selectOnly().join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.trashedLocalAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..addColumns([_db.trashedLocalAssetEntity.id])
|
||||
..where(
|
||||
_db.trashedLocalAssetEntity.id.equalsExp(_db.trashSyncEntity.id) &
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
|
||||
);
|
||||
|
||||
final rows =
|
||||
await (_db.select(_db.trashSyncEntity).join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum)),
|
||||
])..where(
|
||||
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.appTrashed) &
|
||||
_db.trashSyncEntity.triggerSource.equalsValue(TrashTriggerSource.remoteSync) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
existsQuery(selectedTrashedAlbums),
|
||||
))
|
||||
.get();
|
||||
|
||||
return rows.map((r) => r.readTable(_db.trashSyncEntity)).toList();
|
||||
}
|
||||
|
||||
Future<Set<String>> getAppTrashedAssetIds() async {
|
||||
final rows =
|
||||
await (_db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.id])
|
||||
..where(_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.appTrashed)))
|
||||
.get();
|
||||
return rows.map((r) => r.read(_db.trashSyncEntity.id)!).toSet();
|
||||
}
|
||||
|
||||
Stream<int> watchPendingReviewCount() {
|
||||
final countExpr = _db.trashSyncEntity.id.count();
|
||||
|
||||
final q = _db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([countExpr])
|
||||
..where(
|
||||
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
|
||||
_isLocalAssetInBackupSelectedAlbum(),
|
||||
);
|
||||
|
||||
return q.watchSingle().map((row) => row.read(countExpr) ?? 0).distinct();
|
||||
}
|
||||
|
||||
Stream<bool> watchIsAssetPendingById(String localAssetId) {
|
||||
final q = _db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.id])
|
||||
..where(
|
||||
_db.trashSyncEntity.id.equals(localAssetId) &
|
||||
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
|
||||
_isLocalAssetInBackupSelectedAlbum(),
|
||||
)
|
||||
..limit(1);
|
||||
return q.watchSingleOrNull().map((row) => row != null).distinct();
|
||||
}
|
||||
|
||||
Stream<bool> watchIsAssetPendingByChecksum(String checksum) {
|
||||
final q = _db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.id])
|
||||
..where(
|
||||
_db.trashSyncEntity.checksum.equals(checksum) &
|
||||
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
|
||||
_isLocalAssetInBackupSelectedAlbum(),
|
||||
)
|
||||
..limit(1);
|
||||
return q.watchSingleOrNull().map((row) => row != null).distinct();
|
||||
}
|
||||
|
||||
Expression<bool> _isLocalAssetInBackupSelectedAlbum() {
|
||||
final selectedAlbumQ =
|
||||
_db.localAlbumAssetEntity.selectOnly().join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.trashSyncEntity.id) &
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
|
||||
);
|
||||
return existsQuery(selectedAlbumQ);
|
||||
}
|
||||
|
||||
Future<int> cleanup() async {
|
||||
return _db.transaction(() async {
|
||||
final aliveChecksums = _db.selectOnly(_db.remoteAssetEntity)
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
..where(_db.remoteAssetEntity.deletedAt.isNull());
|
||||
final rule1 = await (_db.delete(_db.trashSyncEntity)..where((t) => t.checksum.isInQuery(aliveChecksums))).go();
|
||||
|
||||
final liveLocalIds = _db.selectOnly(_db.localAssetEntity)..addColumns([_db.localAssetEntity.id]);
|
||||
final rule2 =
|
||||
await (_db.delete(_db.trashSyncEntity)..where(
|
||||
(t) => t.id.isNotInQuery(liveLocalIds) & t.decision.equalsValue(TrashStateDecision.appTrashed).not(),
|
||||
))
|
||||
.go();
|
||||
|
||||
return rule1 + rule2;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'package:drift/drift.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/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
@@ -57,9 +57,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset());
|
||||
}
|
||||
|
||||
/// Applies resulted snapshot of trashed assets:
|
||||
/// - upserts incoming rows
|
||||
/// - deletes rows that are not present in the snapshot
|
||||
Future<void> processTrashSnapshot(Iterable<TrashedAsset> trashedAssets) async {
|
||||
if (trashedAssets.isEmpty) {
|
||||
await _db.delete(_db.trashedLocalAssetEntity).go();
|
||||
@@ -86,7 +83,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
isFavorite: Value(item.asset.isFavorite),
|
||||
orientation: Value(item.asset.orientation),
|
||||
playbackStyle: Value(item.asset.playbackStyle),
|
||||
source: TrashOrigin.localSync,
|
||||
source: .localSync,
|
||||
);
|
||||
|
||||
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
|
||||
@@ -104,9 +101,11 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
_db.trashedLocalAssetEntity,
|
||||
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
|
||||
final idToDelete = existingIds.where((id) => !assetIds.contains(id));
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
await _db.batch((batch) {
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
(_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -125,7 +124,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||
}
|
||||
|
||||
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
|
||||
Future<void> trashLocalAssets(Map<String, Iterable<RemoteDeletedLocalAsset>> assetsByAlbums) async {
|
||||
if (assetsByAlbums.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
@@ -134,7 +133,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
final idToDelete = <String>{};
|
||||
|
||||
for (final entry in assetsByAlbums.entries) {
|
||||
for (final asset in entry.value) {
|
||||
for (final record in entry.value) {
|
||||
final asset = record.asset;
|
||||
idToDelete.add(asset.id);
|
||||
companions.add(
|
||||
TrashedLocalAssetEntityCompanion(
|
||||
@@ -264,32 +264,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, List<LocalAsset>>> getToTrash() async {
|
||||
final result = <String, List<LocalAsset>>{};
|
||||
|
||||
final rows =
|
||||
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
||||
),
|
||||
])..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.remoteAssetEntity.deletedAt.isNotNull(),
|
||||
))
|
||||
.get();
|
||||
|
||||
for (final row in rows) {
|
||||
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
|
||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||
(result[albumId] ??= <LocalAsset>[]).add(asset);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
//attempt to reuse existing checksums
|
||||
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
|
||||
final localChecksumById = <String, String>{};
|
||||
|
||||
@@ -8,13 +8,13 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
_searchController = TextEditingController();
|
||||
_searchFocusNode = FocusNode();
|
||||
|
||||
_enableSyncUploadAlbum.value = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
||||
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
ref.read(backupAlbumProvider.notifier).getAll();
|
||||
|
||||
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
return;
|
||||
}
|
||||
|
||||
final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
||||
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
final selectedAlbums = ref
|
||||
.read(backupAlbumProvider)
|
||||
.where((a) => a.backupSelection == BackupSelection.selected)
|
||||
@@ -103,7 +103,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
return;
|
||||
}
|
||||
|
||||
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled;
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
|
||||
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
|
||||
|
||||
@@ -3,14 +3,12 @@ import 'dart:async';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
@@ -21,18 +19,20 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
bool hasPopped = false;
|
||||
final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
|
||||
final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
|
||||
final previousBackup = ref.read(metadataProvider).appConfig.backup;
|
||||
final previousCellularForVideos = previousBackup.useCellularForVideos;
|
||||
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
// There is an issue with Flutter where the pop event
|
||||
// can be triggered multiple times, so we guard it with _hasPopped
|
||||
|
||||
final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
|
||||
final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
|
||||
final currentBackup = ref.read(metadataProvider).appConfig.backup;
|
||||
final currentCellularForVideos = currentBackup.useCellularForVideos;
|
||||
final currentCellularForPhotos = currentBackup.useCellularForPhotos;
|
||||
|
||||
if (currentWifiReqForVideos == previousWifiReqForVideos &&
|
||||
currentWifiReqForPhotos == previousWifiReqForPhotos) {
|
||||
if (currentCellularForVideos == previousCellularForVideos &&
|
||||
currentCellularForPhotos == previousCellularForPhotos) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled;
|
||||
if (!isBackupEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
class SettingsHeader {
|
||||
String key = "";
|
||||
@@ -24,17 +22,14 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
final headers = useState<List<SettingsHeader>>([]);
|
||||
final setInitialHeaders = useState(false);
|
||||
|
||||
var headersStr = Store.get(StoreKey.customHeaders, "");
|
||||
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
if (!setInitialHeaders.value) {
|
||||
if (headersStr.isNotEmpty) {
|
||||
var customHeaders = jsonDecode(headersStr) as Map;
|
||||
customHeaders.forEach((k, v) {
|
||||
final header = SettingsHeader();
|
||||
header.key = k;
|
||||
header.value = v;
|
||||
headers.value.add(header);
|
||||
});
|
||||
}
|
||||
storedHeaders.forEach((k, v) {
|
||||
final header = SettingsHeader();
|
||||
header.key = k;
|
||||
header.value = v;
|
||||
headers.value.add(header);
|
||||
});
|
||||
|
||||
// add first one to help the user
|
||||
if (headers.value.isEmpty) {
|
||||
@@ -88,8 +83,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
|
||||
final headersMap = {};
|
||||
for (var header in headers) {
|
||||
final headersMap = <String, String>{};
|
||||
for (final header in headers) {
|
||||
final key = header.key.trim();
|
||||
final value = header.value.trim();
|
||||
|
||||
@@ -99,8 +94,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
headersMap[key] = value;
|
||||
}
|
||||
|
||||
var encoded = jsonEncode(headersMap);
|
||||
await Store.put(StoreKey.customHeaders, encoded);
|
||||
await ref.read(metadataProvider).write(MetadataKey.networkCustomHeaders, headersMap);
|
||||
await ref.read(apiServiceProvider).updateHeaders();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
@@ -340,7 +341,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
await backgroundManager.hashAssets();
|
||||
}
|
||||
|
||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
||||
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -369,7 +370,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
}
|
||||
|
||||
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
|
||||
final isEnableBackup = Store.get(StoreKey.enableBackup, false);
|
||||
final isEnableBackup = MetadataRepository.instance.appConfig.backup.enabled;
|
||||
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart';
|
||||
@@ -28,71 +27,41 @@ class SharedLinkPage extends HookConsumerWidget {
|
||||
}, []);
|
||||
|
||||
Widget buildNoShares() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: const Text(
|
||||
"shared_link_manage_links",
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Icon(Icons.link_off, size: 100, color: context.themeData.iconTheme.color?.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.link_off, size: 100, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)),
|
||||
const SizedBox(height: 20),
|
||||
const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSharesList(List<SharedLink> links) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0),
|
||||
child: Text(
|
||||
"shared_link_manage_links",
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.textTheme.labelLarge?.color?.withAlpha(200)),
|
||||
).tr(),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth > 600) {
|
||||
// Two column
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisExtent: 180,
|
||||
),
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SharedLinkItem(links.elementAt(index));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Single column
|
||||
return ListView.builder(
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SharedLinkItem(links.elementAt(index));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => constraints.maxWidth > 600
|
||||
? GridView.builder(
|
||||
key: const PageStorageKey('shared-links-grid'),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisExtent: 180,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) => SharedLinkItem(links[index]),
|
||||
)
|
||||
: ListView.separated(
|
||||
key: const PageStorageKey('shared-links-list'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) => SharedLinkItem(links[index]),
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,20 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/services/shared_link.service.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class SharedLinkEditPage extends HookConsumerWidget {
|
||||
static const int maxFutureDate = 365 * 2;
|
||||
|
||||
final SharedLink? existingLink;
|
||||
final List<String>? assetsList;
|
||||
final String? albumId;
|
||||
@@ -23,71 +28,82 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const padding = 20.0;
|
||||
final themeData = context.themeData;
|
||||
final colorScheme = context.colorScheme;
|
||||
final externalDomain = ref.watch(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
|
||||
final displayServerUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
|
||||
final expiryPresets = <(Duration, String)>[
|
||||
(Duration.zero, context.t.never),
|
||||
(const Duration(minutes: 30), context.t.shared_link_edit_expire_after_option_minutes(count: 30)),
|
||||
(const Duration(hours: 1), context.t.shared_link_edit_expire_after_option_hour),
|
||||
(const Duration(hours: 6), context.t.shared_link_edit_expire_after_option_hours(count: 6)),
|
||||
(const Duration(days: 1), context.t.shared_link_edit_expire_after_option_day),
|
||||
(const Duration(days: 7), context.t.shared_link_edit_expire_after_option_days(count: 7)),
|
||||
(const Duration(days: 30), context.t.shared_link_edit_expire_after_option_days(count: 30)),
|
||||
(const Duration(days: 90), context.t.shared_link_edit_expire_after_option_months(count: 3)),
|
||||
(const Duration(days: 365), context.t.shared_link_edit_expire_after_option_year(count: 1)),
|
||||
];
|
||||
final descriptionController = useTextEditingController(text: existingLink?.description ?? "");
|
||||
final descriptionFocusNode = useFocusNode();
|
||||
final passwordController = useTextEditingController(text: existingLink?.password ?? "");
|
||||
final slugController = useTextEditingController(text: existingLink?.slug ?? "");
|
||||
final slugFocusNode = useFocusNode();
|
||||
useListenable(slugController);
|
||||
final showMetadata = useState(existingLink?.showMetadata ?? true);
|
||||
final allowDownload = useState(existingLink?.allowDownload ?? true);
|
||||
final allowUpload = useState(existingLink?.allowUpload ?? false);
|
||||
final editExpiry = useState(false);
|
||||
final expiryAfter = useState(0);
|
||||
final expiryAfter = useState<DateTime?>(existingLink?.expiresAt?.toLocal());
|
||||
final selectedPresetIndex = useState<int?>(existingLink?.expiresAt == null ? 0 : null);
|
||||
final newShareLink = useState("");
|
||||
|
||||
Widget buildSharedLinkRow({required String leading, required String content}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
content,
|
||||
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(leading, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLinkTitle() {
|
||||
if (existingLink != null) {
|
||||
if (existingLink!.type == SharedLinkSource.album) {
|
||||
return Row(
|
||||
children: [
|
||||
const Text('public_album', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
existingLink!.title,
|
||||
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
return buildSharedLinkRow(leading: context.t.public_album, content: existingLink!.title);
|
||||
}
|
||||
|
||||
if (existingLink!.type == SharedLinkSource.individual) {
|
||||
return Row(
|
||||
children: [
|
||||
const Text('shared_link_individual_shared', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
existingLink!.description ?? "--",
|
||||
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
return buildSharedLinkRow(
|
||||
leading: context.t.shared_link_individual_shared,
|
||||
content: existingLink!.description ?? "--",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return const Text("create_link_to_share_description", style: TextStyle(fontWeight: FontWeight.bold)).tr();
|
||||
return Text(context.t.create_link_to_share_description, style: const TextStyle(fontWeight: FontWeight.bold));
|
||||
}
|
||||
|
||||
Widget buildDescriptionField() {
|
||||
return TextField(
|
||||
controller: descriptionController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
focusNode: descriptionFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'description'.tr(),
|
||||
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
labelText: context.t.description,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'shared_link_edit_description_hint'.tr(),
|
||||
hintText: context.t.shared_link_edit_description_hint,
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
|
||||
),
|
||||
onTapOutside: (_) => descriptionFocusNode.unfocus(),
|
||||
);
|
||||
@@ -96,16 +112,14 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
Widget buildPasswordField() {
|
||||
return TextField(
|
||||
controller: passwordController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'password'.tr(),
|
||||
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
labelText: context.t.password,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'shared_link_edit_password_hint'.tr(),
|
||||
hintText: context.t.shared_link_edit_password_hint,
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -113,18 +127,16 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
Widget buildSlugField() {
|
||||
return TextField(
|
||||
controller: slugController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
focusNode: slugFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'custom_url'.tr(),
|
||||
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
labelText: slugController.text.isNotEmpty ? context.t.custom_url : null,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'custom_url'.tr(),
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
|
||||
hintText: context.t.custom_url,
|
||||
prefixText: slugController.text.isNotEmpty ? '/s/' : null,
|
||||
prefixStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
onTapOutside: (_) => slugFocusNode.unfocus(),
|
||||
);
|
||||
@@ -133,145 +145,182 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
Widget buildShowMetaButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showMetadata.value,
|
||||
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
|
||||
activeThumbColor: colorScheme.primary,
|
||||
onChanged: (value) => showMetadata.value = value,
|
||||
dense: true,
|
||||
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
|
||||
title: Text(
|
||||
context.t.show_metadata,
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAllowDownloadButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: allowDownload.value,
|
||||
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
|
||||
activeThumbColor: colorScheme.primary,
|
||||
onChanged: (value) => allowDownload.value = value,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"allow_public_user_to_download",
|
||||
context.t.allow_public_user_to_download,
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAllowUploadButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: allowUpload.value,
|
||||
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
|
||||
activeThumbColor: colorScheme.primary,
|
||||
onChanged: (value) => allowUpload.value = value,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"allow_public_user_to_upload",
|
||||
context.t.allow_public_user_to_upload,
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEditExpiryButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: editExpiry.value,
|
||||
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
|
||||
activeThumbColor: colorScheme.primary,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"change_expiration_time",
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
String formatDateTime(DateTime dateTime) => DateFormat.yMMMd(context.locale.toString()).add_Hm().format(dateTime);
|
||||
|
||||
DateTime? getExpiresAtFromPreset(Duration preset) => preset == Duration.zero ? null : DateTime.now().add(preset);
|
||||
|
||||
Future<void> selectDate() async {
|
||||
final today = DateTime.now();
|
||||
final safeInitialDate = expiryAfter.value ?? today.add(const Duration(days: 7));
|
||||
final initialDate = safeInitialDate.isBefore(today) ? today : safeInitialDate;
|
||||
|
||||
final selectedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initialDate,
|
||||
firstDate: today,
|
||||
lastDate: today.add(const Duration(days: maxFutureDate)),
|
||||
);
|
||||
|
||||
if (selectedDate != null && context.mounted) {
|
||||
final isToday =
|
||||
selectedDate.year == today.year && selectedDate.month == today.month && selectedDate.day == today.day;
|
||||
final initialTime = isToday ? TimeOfDay.fromDateTime(today) : const TimeOfDay(hour: 12, minute: 0);
|
||||
|
||||
final selectedTime = await showTimePicker(context: context, initialTime: initialTime);
|
||||
|
||||
if (selectedTime != null) {
|
||||
final now = DateTime.now();
|
||||
var finalDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
selectedTime.hour,
|
||||
selectedTime.minute,
|
||||
);
|
||||
|
||||
if (finalDateTime.isBefore(now) && isToday) {
|
||||
finalDateTime = now;
|
||||
}
|
||||
|
||||
selectedPresetIndex.value = null;
|
||||
expiryAfter.value = finalDateTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildExpiryAfterButton() {
|
||||
return DropdownMenu(
|
||||
label: Text(
|
||||
"expire_after",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
).tr(),
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
width: context.width - 40,
|
||||
initialSelection: expiryAfter.value,
|
||||
enabled: newShareLink.value.isEmpty && (existingLink == null || editExpiry.value),
|
||||
onSelected: (value) {
|
||||
expiryAfter.value = value!;
|
||||
},
|
||||
dropdownMenuEntries: [
|
||||
DropdownMenuEntry(value: 0, label: "never".tr()),
|
||||
DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "shared_link_edit_expire_after_option_minutes".tr(namedArgs: {'count': "30"}),
|
||||
),
|
||||
DropdownMenuEntry(value: 60, label: "shared_link_edit_expire_after_option_hour".tr()),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 6,
|
||||
label: "shared_link_edit_expire_after_option_hours".tr(namedArgs: {'count': "6"}),
|
||||
),
|
||||
DropdownMenuEntry(value: 60 * 24, label: "shared_link_edit_expire_after_option_day".tr()),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 7,
|
||||
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "7"}),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 30,
|
||||
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "30"}),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 30 * 3,
|
||||
label: "shared_link_edit_expire_after_option_months".tr(namedArgs: {'count': "3"}),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 30 * 12,
|
||||
label: "shared_link_edit_expire_after_option_year".tr(namedArgs: {'count': "1"}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void copyLinkToClipboard() {
|
||||
Clipboard.setData(ClipboardData(text: newShareLink.value)).then((_) {
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"shared_link_clipboard_copied_massage",
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
).tr(),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildNewLinkField() {
|
||||
return Column(
|
||||
return ExpansionTile(
|
||||
title: Text(
|
||||
context.t.expire_after,
|
||||
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
expiryAfter.value == null ? context.t.shared_link_expires_never : formatDateTime(expiryAfter.value!),
|
||||
style: TextStyle(color: themeData.colorScheme.primary),
|
||||
),
|
||||
children: [
|
||||
const Padding(padding: EdgeInsets.only(top: 20, bottom: 20), child: Divider()),
|
||||
TextFormField(
|
||||
readOnly: true,
|
||||
initialValue: newShareLink.value,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
|
||||
suffixIcon: IconButton(onPressed: copyLinkToClipboard, icon: const Icon(Icons.copy)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.maybePop();
|
||||
},
|
||||
child: const Text("done", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(expiryPresets.length, (index) {
|
||||
final preset = expiryPresets[index];
|
||||
return ChoiceChip(
|
||||
label: Text(preset.$2),
|
||||
selected: selectedPresetIndex.value == index,
|
||||
onSelected: (_) {
|
||||
selectedPresetIndex.value = index;
|
||||
expiryAfter.value = getExpiresAtFromPreset(preset.$1);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (expiryAfter.value != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: selectDate,
|
||||
icon: const Icon(Icons.edit_calendar),
|
||||
label: Text(context.t.edit_date_and_time),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
DateTime calculateExpiry() {
|
||||
return DateTime.now().add(Duration(minutes: expiryAfter.value));
|
||||
Future<void> copyToClipboard(String link) async {
|
||||
await Clipboard.setData(ClipboardData(text: link));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.t.shared_link_clipboard_copied_massage,
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLinkCopyField(String link) {
|
||||
return TextFormField(
|
||||
readOnly: true,
|
||||
onTap: () => copyToClipboard(link),
|
||||
initialValue: link,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
|
||||
suffixIcon: IconButton(onPressed: () => Share.share(link), icon: const Icon(Icons.share)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildNewLinkReadyScreen() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_link, size: 100, color: themeData.colorScheme.primary),
|
||||
const SizedBox(height: 20),
|
||||
buildLinkCopyField(newShareLink.value),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.maybePop(),
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(context.t.done, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? calculateExpiry() => expiryAfter.value;
|
||||
|
||||
Future<void> handleNewLink() async {
|
||||
final newLink = await ref
|
||||
.read(sharedLinkServiceProvider)
|
||||
@@ -284,30 +333,30 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
description: descriptionController.text.isEmpty ? null : descriptionController.text,
|
||||
password: passwordController.text.isEmpty ? null : passwordController.text,
|
||||
slug: slugController.text.isEmpty ? null : slugController.text,
|
||||
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
|
||||
expiresAt: calculateExpiry()?.toUtc(),
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
|
||||
await ref.read(serverInfoProvider.notifier).getServerConfig();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
|
||||
|
||||
var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
|
||||
if (serverUrl != null && !serverUrl.endsWith('/')) {
|
||||
serverUrl += '/';
|
||||
}
|
||||
final serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
|
||||
|
||||
if (newLink != null && serverUrl != null) {
|
||||
final hasSlug = newLink.slug?.isNotEmpty == true;
|
||||
final urlPath = hasSlug ? newLink.slug : newLink.key;
|
||||
final basePath = hasSlug ? 's' : 'share';
|
||||
newShareLink.value = "$serverUrl$basePath/$urlPath";
|
||||
copyLinkToClipboard();
|
||||
} else if (newLink == null) {
|
||||
if (newLink != null) {
|
||||
newShareLink.value = buildSharedLinkUrl(baseUrl: serverUrl, slug: newLink.slug, key: newLink.key) ?? '';
|
||||
await copyToClipboard(newShareLink.value);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
msg: 'shared_link_create_error'.tr(),
|
||||
msg: context.t.shared_link_create_error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -348,8 +397,9 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
slug = existingLink!.slug;
|
||||
}
|
||||
|
||||
if (editExpiry.value) {
|
||||
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
|
||||
final newExpiry = expiryAfter.value;
|
||||
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
|
||||
expiry = newExpiry;
|
||||
changeExpiry = true;
|
||||
}
|
||||
|
||||
@@ -363,69 +413,115 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
description: desc,
|
||||
password: password,
|
||||
slug: slug,
|
||||
expiresAt: expiry,
|
||||
expiresAt: expiry?.toUtc(),
|
||||
changeExpiry: changeExpiry,
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
await context.maybePop();
|
||||
}
|
||||
|
||||
Future<void> handleDeleteLink() async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) => ConfirmDialog(
|
||||
title: "delete_shared_link_dialog_title",
|
||||
content: "confirm_delete_shared_link",
|
||||
onOk: () async {
|
||||
await ref.read(sharedLinkServiceProvider).deleteSharedLink(existingLink!.id);
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
if (context.mounted) {
|
||||
await context.maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(existingLink == null ? "create_link_to_share" : "edit_link").tr(),
|
||||
title: Text(existingLink == null ? context.t.create_link_to_share : context.t.edit_link),
|
||||
elevation: 0,
|
||||
leading: const CloseButton(),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildShowMetaButton(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildAllowDownloadButton(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: 20, bottom: 20),
|
||||
child: buildAllowUploadButton(),
|
||||
),
|
||||
if (existingLink != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildEditExpiryButton(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildExpiryAfterButton(),
|
||||
),
|
||||
if (newShareLink.value.isEmpty)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: padding + 10, bottom: padding),
|
||||
child: ElevatedButton(
|
||||
onPressed: existingLink != null ? handleEditLink : handleNewLink,
|
||||
child: Text(
|
||||
existingLink != null ? "shared_link_edit_submit_button" : "create_link",
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
child: newShareLink.value.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
buildLinkTitle(),
|
||||
if (existingLink != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
buildLinkCopyField(
|
||||
buildSharedLinkUrl(
|
||||
baseUrl: displayServerUrl,
|
||||
slug: existingLink!.slug,
|
||||
key: existingLink!.key,
|
||||
) ??
|
||||
'',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
buildDescriptionField(),
|
||||
const SizedBox(height: 16),
|
||||
buildPasswordField(),
|
||||
const SizedBox(height: 16),
|
||||
buildSlugField(),
|
||||
const SizedBox(height: 16),
|
||||
buildShowMetaButton(),
|
||||
const SizedBox(height: 16),
|
||||
buildAllowDownloadButton(),
|
||||
const SizedBox(height: 16),
|
||||
buildAllowUploadButton(),
|
||||
const SizedBox(height: 16),
|
||||
buildExpiryAfterButton(),
|
||||
const SizedBox(height: 24),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (existingLink != null)
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: themeData.colorScheme.error,
|
||||
side: BorderSide(color: themeData.colorScheme.error),
|
||||
),
|
||||
onPressed: handleDeleteLink,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
context.t.delete,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: existingLink != null ? handleEditLink : handleNewLink,
|
||||
label: Text(
|
||||
existingLink != null ? context.t.shared_link_edit_submit_button : context.t.create_link,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (newShareLink.value.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildNewLinkField(),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Center(child: buildNewLinkReadyScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+76
@@ -654,6 +654,82 @@ class NativeSyncApi {
|
||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
|
||||
@@ -37,6 +37,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
||||
|
||||
final scrollView = CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
ImmichSliverAppBar(
|
||||
snap: false,
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftAssetSelectionTimelinePage extends ConsumerWidget {
|
||||
@@ -22,17 +21,13 @@ class DriftAssetSelectionTimelinePage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
timelineServiceProvider.overrideWith((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) {
|
||||
throw Exception('User must be logged in to access asset selection timeline');
|
||||
}
|
||||
|
||||
final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(user.id);
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: const Timeline(),
|
||||
child: const Timeline(showStorageIndicator: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,17 +179,14 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
}
|
||||
|
||||
final album = await ref
|
||||
.watch(remoteAlbumProvider.notifier)
|
||||
.createAlbum(
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.createAlbumWithAssets(
|
||||
title: title,
|
||||
description: albumDescriptionController.text.trim(),
|
||||
assetIds: selectedAssets.map((asset) {
|
||||
final remoteAsset = asset as RemoteAsset;
|
||||
return remoteAsset.id;
|
||||
}).toList(),
|
||||
assets: selectedAssets,
|
||||
);
|
||||
|
||||
if (album != null) {
|
||||
if (album != null && context.mounted) {
|
||||
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/pending_uploads_banner.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
@@ -39,7 +40,8 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
}
|
||||
|
||||
Future<void> addAssets(BuildContext context) async {
|
||||
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id);
|
||||
final notifier = ref.read(remoteAlbumProvider.notifier);
|
||||
final albumAssets = await notifier.getAssets(_album.id);
|
||||
|
||||
final newAssets = await context.pushRoute<Set<BaseAsset>>(
|
||||
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
|
||||
@@ -49,17 +51,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final added = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.addAssets(
|
||||
_album.id,
|
||||
newAssets.map((asset) {
|
||||
final remoteAsset = asset as RemoteAsset;
|
||||
return remoteAsset.id;
|
||||
}).toList(),
|
||||
);
|
||||
final added = await notifier.addAssetsToAlbum(_album.id, newAssets);
|
||||
|
||||
if (added > 0) {
|
||||
if (added > 0 && context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}),
|
||||
@@ -186,6 +180,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
currentRemoteAlbumScopedProvider.overrideWithValue(_album),
|
||||
],
|
||||
child: Timeline(
|
||||
topSliverWidget: PendingUploadsBanner(albumId: _album.id),
|
||||
appBar: RemoteAlbumSliverAppBar(
|
||||
icon: Icons.photo_album_outlined,
|
||||
kebabMenu: _AlbumKebabMenu(
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftSlideshowPage extends ConsumerStatefulWidget {
|
||||
final TimelineService timeline;
|
||||
|
||||
const DriftSlideshowPage({super.key, required this.timeline});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
|
||||
}
|
||||
|
||||
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
late SlideshowConfig _config;
|
||||
late final PageController _pageController;
|
||||
late final Stopwatch _stopwatch;
|
||||
late Timer _timer;
|
||||
late int _index;
|
||||
late int _nextIndex;
|
||||
bool _paused = false;
|
||||
bool _showAppBar = false;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_config = ref.read(appConfigProvider.select((s) => s.slideshow));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
|
||||
_pageController = PageController(initialPage: _index);
|
||||
_stopwatch = Stopwatch();
|
||||
_createTimer();
|
||||
_updateNextIndex();
|
||||
ref.listenManual(appConfigProvider.select((s) => s.slideshow), _onConfigChanged);
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
unawaited(WakelockPlus.enable());
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_pageController.dispose();
|
||||
unawaited(WakelockPlus.disable());
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _play() {
|
||||
final asset = widget.timeline.getAssetSafe(_index)!;
|
||||
|
||||
if (asset.isImage) {
|
||||
_createTimer();
|
||||
} else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).play();
|
||||
} else {
|
||||
_nextPage();
|
||||
}
|
||||
|
||||
_updateNextIndex();
|
||||
|
||||
setState(() {
|
||||
_paused = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _pause() {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
|
||||
final asset = widget.timeline.getAssetSafe(_index)!;
|
||||
|
||||
if (!asset.isImage) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).pause();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_paused = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _onConfigChanged(SlideshowConfig? previous, SlideshowConfig next) {
|
||||
if (_config == next) {
|
||||
return;
|
||||
}
|
||||
|
||||
final durationChanged = _config.duration != next.duration;
|
||||
_config = next;
|
||||
_updateNextIndex();
|
||||
|
||||
final asset = widget.timeline.getAssetSafe(_index);
|
||||
if (durationChanged && !_paused && asset?.isImage == true) {
|
||||
_timer.cancel();
|
||||
_createTimer();
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _updateNextIndex() {
|
||||
_nextIndex = switch (_config.direction) {
|
||||
SlideshowDirection.forward => _index + 1,
|
||||
SlideshowDirection.backward => _index - 1,
|
||||
SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!,
|
||||
};
|
||||
|
||||
if (!widget.timeline.hasRange(_nextIndex, 1)) {
|
||||
widget.timeline.preloadAssets(_nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void _nextPage() async {
|
||||
if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) {
|
||||
if (_config.repeat) {
|
||||
final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
|
||||
await widget.timeline.preloadAssets(wrapped);
|
||||
_pageController.jumpToPage(wrapped);
|
||||
} else {
|
||||
setState(() {
|
||||
_paused = true;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!widget.timeline.hasRange(_nextIndex, 1)) {
|
||||
await widget.timeline.preloadAssets(_nextIndex);
|
||||
}
|
||||
|
||||
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
|
||||
_pageController.jumpToPage(_nextIndex);
|
||||
} else {
|
||||
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
|
||||
}
|
||||
}
|
||||
|
||||
void _createTimer() {
|
||||
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () {
|
||||
_stopwatch.stop();
|
||||
_stopwatch.reset();
|
||||
_nextPage();
|
||||
});
|
||||
|
||||
_stopwatch.start();
|
||||
}
|
||||
|
||||
void _pageChanged(int page) {
|
||||
final asset = widget.timeline.getAssetSafe(page)!;
|
||||
|
||||
setState(() {
|
||||
_index = page;
|
||||
|
||||
if (!asset.isImage) {
|
||||
_paused = false;
|
||||
}
|
||||
});
|
||||
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_stopwatch.reset();
|
||||
|
||||
if (!_paused && asset.isImage) {
|
||||
_createTimer();
|
||||
}
|
||||
|
||||
_updateNextIndex();
|
||||
}
|
||||
|
||||
void _onTapUp() async {
|
||||
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_showAppBar = !_showAppBar;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _getProgressBar(BuildContext context) {
|
||||
final asset = widget.timeline.getAssetSafe(_index);
|
||||
|
||||
if (asset == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
key: Key(_index.toString()),
|
||||
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value:
|
||||
ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
|
||||
asset.duration.inMilliseconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _getBlur(BuildContext context, int index) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.2)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getPhotoView(BuildContext context, int index) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
final scale = _config.look == SlideshowLook.cover
|
||||
? PhotoViewComputedScale.covered
|
||||
: PhotoViewComputedScale.contained;
|
||||
final isCurrent = _index == index;
|
||||
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
|
||||
|
||||
if (asset.isImage) {
|
||||
final zoomOut = index % 2 == 1;
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
tween: Tween<double>(
|
||||
begin: progress,
|
||||
end: _paused
|
||||
? progress
|
||||
: zoomOut
|
||||
? 0.0
|
||||
: 1.0,
|
||||
),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale * (1.0 + value / 10.0),
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
|
||||
final position = ref.read(videoPlayerProvider(asset.heroTag)).position;
|
||||
|
||||
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
|
||||
_nextPage();
|
||||
} else if (status == VideoPlaybackStatus.playing) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false);
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
disableScaleGestures: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale,
|
||||
child: NativeVideoViewer(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showAppBar,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showAppBar ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: Column(
|
||||
children: [
|
||||
AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text("slideshow".t(context: context)),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _paused ? _play : _pause,
|
||||
icon: Icon(_paused ? Icons.play_arrow : Icons.pause),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
_pause();
|
||||
context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer));
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
_getProgressBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.black,
|
||||
body: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const FastClampingScrollPhysics(),
|
||||
itemCount: widget.timeline.totalAssets,
|
||||
onPageChanged: _pageChanged,
|
||||
itemBuilder: (context, index) => Stack(
|
||||
children: [
|
||||
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
|
||||
_getPhotoView(context, index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_sync_bottom_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftTrashSyncReviewPage extends ConsumerWidget {
|
||||
const DriftTrashSyncReviewPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) {
|
||||
throw Exception('User must be logged in to access trash');
|
||||
}
|
||||
final timelineService = ref.watch(timelineFactoryProvider).toTrashSyncReview();
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: Timeline(
|
||||
appBar: SliverAppBar(
|
||||
title: Text('asset_out_of_sync_title'.tr()),
|
||||
floating: true,
|
||||
snap: true,
|
||||
pinned: true,
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
),
|
||||
topSliverWidgetHeight: 24,
|
||||
topSliverWidget: SliverPadding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 72.0,
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
|
||||
return outOfSyncCount > 0
|
||||
? const Text('asset_out_of_sync_trash_subtitle').tr()
|
||||
: Center(
|
||||
child: Text('asset_out_of_sync_trash_subtitle_result', style: context.textTheme.bodyLarge).tr(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomSheet: const TrashSyncBottomBar(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
expanded: true,
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
|
||||
child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -50,10 +50,13 @@ class BaseActionButton extends ConsumerWidget {
|
||||
final iconColor = this.iconColor;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
||||
leadingIcon: Icon(iconData, color: iconColor),
|
||||
style: MenuItemButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
leadingIcon: Icon(iconData, color: iconColor, size: 20),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class BulkTagAssetsActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const BulkTagAssetsActionButton({super.key, required this.source});
|
||||
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
final result = await ref.read(actionProvider.notifier).tagAssets(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success
|
||||
? 'tagged_assets'.t(context: context, args: {'count': result.count.toString()})
|
||||
: 'errors.failed_to_tag_assets'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.sell_outlined,
|
||||
label: "control_bottom_app_bar_add_tags".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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';
|
||||
|
||||
void showKeepResultToast(BuildContext context, ActionResult result) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final message = result.success
|
||||
? 'assets_denied_to_moved_to_trash_count'.t(args: {'count': '${result.count}'})
|
||||
: 'scaffold_body_error_occurred'.t();
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: message,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
/// This deny move to trash action has the following behavior:
|
||||
/// - Deny moving to the local trash those assets that are in the remote trash.
|
||||
///
|
||||
/// This action is used when the asset is selected in multi-selection mode in the trash page
|
||||
class KeepOnDeviceActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final void Function(ActionResult result) onResult;
|
||||
|
||||
const KeepOnDeviceActionButton({super.key, required this.source, required this.onResult});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
final actionNotifier = ref.read(actionProvider.notifier);
|
||||
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
|
||||
final result = await actionNotifier.resolveRemoteTrash(source, keep: true);
|
||||
onResult.call(result);
|
||||
multiSelectNotifier.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const iconData = Icons.cloud_off_outlined;
|
||||
return source == ActionSource.viewer
|
||||
? BaseActionButton(
|
||||
maxWidth: 110.0,
|
||||
iconData: iconData,
|
||||
label: 'keep'.t(),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
)
|
||||
: TextButton.icon(
|
||||
icon: const Icon(iconData),
|
||||
label: Text('keep_on_device'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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';
|
||||
|
||||
void showTrashResultToast(BuildContext context, ActionResult result) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final message = result.success
|
||||
? 'assets_moved_to_trash_count'.t(args: {'count': '${result.count}'})
|
||||
: 'errors.something_went_wrong'.t();
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: message,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.info : ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
/// This move to trash action has the following behavior:
|
||||
/// - Allows moving to the local trash those assets that are in the remote trash.
|
||||
///
|
||||
/// This action is used when the asset is selected in multi-selection mode in the review out-of-sync changes
|
||||
class MoveToTrashActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final void Function(ActionResult result) onResult;
|
||||
|
||||
const MoveToTrashActionButton({super.key, required this.source, required this.onResult});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final selectedCount = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
|
||||
final assetViewerNotifier = ref.read(assetViewerProvider.notifier);
|
||||
assetViewerNotifier.setControls(false);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('asset_out_of_sync_trash_confirmation_title'.tr()),
|
||||
content: Text('asset_out_of_sync_trash_confirmation_text'.t(args: {'count': '$selectedCount'})),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('cancel'.t(context: context)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
|
||||
child: Text('control_bottom_app_bar_trash_from_immich'.tr()),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true) {
|
||||
assetViewerNotifier.setControls(true);
|
||||
return;
|
||||
}
|
||||
|
||||
final actionNotifier = ref.read(actionProvider.notifier);
|
||||
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
|
||||
|
||||
final result = await actionNotifier.resolveRemoteTrash(source, keep: false);
|
||||
onResult.call(result);
|
||||
multiSelectNotifier.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const iconData = Icons.delete_forever_outlined;
|
||||
return (source == ActionSource.viewer)
|
||||
? BaseActionButton(
|
||||
maxWidth: 100.0,
|
||||
iconData: iconData,
|
||||
label: 'delete'.tr(),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
)
|
||||
: TextButton.icon(
|
||||
icon: Icon(iconData, color: Colors.red[400]),
|
||||
label: Text(
|
||||
'control_bottom_app_bar_trash_from_immich'.tr(),
|
||||
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SlideshowActionButton extends ConsumerWidget {
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.slideshow,
|
||||
label: "slideshow".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,15 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/album_filter.utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -58,19 +58,11 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
|
||||
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
|
||||
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
|
||||
|
||||
final albumSortMode = AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == savedSortMode,
|
||||
orElse: () => AlbumSortMode.lastModified,
|
||||
);
|
||||
final albumConfig = ref.read(metadataProvider).appConfig.album;
|
||||
|
||||
setState(() {
|
||||
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
|
||||
isGrid = savedIsGrid;
|
||||
sort = AlbumSort(mode: albumConfig.sortMode, isReverse: albumConfig.isReverse);
|
||||
isGrid = albumConfig.isGrid;
|
||||
});
|
||||
|
||||
ref.read(remoteAlbumProvider.notifier).refresh();
|
||||
@@ -102,7 +94,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
setState(() {
|
||||
isGrid = !isGrid;
|
||||
});
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
|
||||
ref.read(metadataProvider).write(MetadataKey.albumIsGrid, isGrid);
|
||||
}
|
||||
|
||||
void changeFilter(QuickFilterMode mode) {
|
||||
@@ -118,9 +110,9 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
this.sort = sort;
|
||||
});
|
||||
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
|
||||
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
|
||||
final metadata = ref.read(metadataProvider);
|
||||
await metadata.write(MetadataKey.albumSortMode, sort.mode);
|
||||
await metadata.write(MetadataKey.albumIsReverse, sort.isReverse);
|
||||
|
||||
await sortAlbums();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
|
||||
/// Pinned banner sliver that surfaces in-flight album uploads directly under
|
||||
/// the album app bar. Renders nothing while the queue is empty. Tapping the
|
||||
/// banner opens a bottom sheet with per-asset progress.
|
||||
class PendingUploadsBanner extends ConsumerWidget {
|
||||
static const double _height = 52;
|
||||
|
||||
final String albumId;
|
||||
|
||||
const PendingUploadsBanner({super.key, required this.albumId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pending = ref.watch(pendingAlbumUploadsProvider(albumId));
|
||||
if (pending.isEmpty) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
final hasFailures = pending.any((p) => p.failed);
|
||||
final clamped = pending.map((p) => p.progress.clamp(0.0, 1.0)).toList(growable: false);
|
||||
final overallProgress = clamped.isEmpty ? 0.0 : clamped.reduce((a, b) => a + b) / clamped.length;
|
||||
final isIndeterminate = overallProgress <= 0.0;
|
||||
|
||||
return SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _PendingUploadsBannerDelegate(
|
||||
height: _height,
|
||||
child: _PendingUploadsBannerContent(
|
||||
albumId: albumId,
|
||||
previewAsset: pending.first.asset,
|
||||
count: pending.length,
|
||||
overallProgress: overallProgress,
|
||||
isIndeterminate: isIndeterminate,
|
||||
hasFailures: hasFailures,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _openSheet(BuildContext context, String albumId) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => _PendingUploadsSheet(albumId: albumId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingUploadsBannerDelegate extends SliverPersistentHeaderDelegate {
|
||||
final double height;
|
||||
final Widget child;
|
||||
|
||||
const _PendingUploadsBannerDelegate({required this.height, required this.child});
|
||||
|
||||
@override
|
||||
double get minExtent => height;
|
||||
|
||||
@override
|
||||
double get maxExtent => height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant _PendingUploadsBannerDelegate oldDelegate) =>
|
||||
height != oldDelegate.height || child != oldDelegate.child;
|
||||
}
|
||||
|
||||
class _PendingUploadsBannerContent extends StatelessWidget {
|
||||
final String albumId;
|
||||
final BaseAsset previewAsset;
|
||||
final int count;
|
||||
final double overallProgress;
|
||||
final bool isIndeterminate;
|
||||
final bool hasFailures;
|
||||
|
||||
const _PendingUploadsBannerContent({
|
||||
required this.albumId,
|
||||
required this.previewAsset,
|
||||
required this.count,
|
||||
required this.overallProgress,
|
||||
required this.isIndeterminate,
|
||||
required this.hasFailures,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percentLabel = isIndeterminate ? '' : ' · ${(overallProgress * 100).toInt()}%';
|
||||
return Material(
|
||||
color: hasFailures ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainerHigh,
|
||||
child: InkWell(
|
||||
onTap: () => PendingUploadsBanner._openSheet(context, albumId),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: SizedBox(width: 32, height: 32, child: Thumbnail.fromAsset(asset: previewAsset)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${'uploading'.t(context: context)} $count$percentLabel',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
if (hasFailures)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Icon(Icons.error_outline, color: context.colorScheme.error, size: 20),
|
||||
),
|
||||
Icon(Icons.chevron_right_rounded, color: context.colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 3,
|
||||
child: LinearProgressIndicator(
|
||||
value: isIndeterminate ? null : overallProgress,
|
||||
backgroundColor: context.colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
hasFailures ? context.colorScheme.error : context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingUploadsSheet extends ConsumerWidget {
|
||||
final String albumId;
|
||||
|
||||
const _PendingUploadsSheet({required this.albumId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pending = ref.watch(pendingAlbumUploadsProvider(albumId));
|
||||
|
||||
// Auto-dismiss when the queue empties.
|
||||
if (pending.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final failedCount = pending.where((p) => p.failed).length;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${'uploading'.t(context: context)} (${pending.length})',
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (failedCount > 0)
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(),
|
||||
icon: const Icon(Icons.clear_rounded, size: 18),
|
||||
label: Text('clear_failed_count'.t(context: context, args: {'count': failedCount})),
|
||||
style: TextButton.styleFrom(foregroundColor: context.colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: pending.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, index) => _PendingUploadTile(entry: pending[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingUploadTile extends StatelessWidget {
|
||||
final PendingAlbumUpload entry;
|
||||
|
||||
const _PendingUploadTile({required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Thumbnail.fromAsset(asset: entry.asset),
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: entry.failed ? Colors.red.withValues(alpha: 0.6) : Colors.black54,
|
||||
child: Center(
|
||||
child: entry.failed
|
||||
? const Icon(Icons.error_outline, color: Colors.white, size: 28)
|
||||
: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
value: entry.progress > 0 ? entry.progress : null,
|
||||
strokeWidth: 2.5,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,10 +56,13 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_DragIntent _dragIntent = _DragIntent.none;
|
||||
Drag? _drag;
|
||||
|
||||
BaseAsset? _asset;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) {
|
||||
return;
|
||||
@@ -71,6 +74,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AssetPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.index != widget.index) {
|
||||
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
@@ -383,7 +394,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
|
||||
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
final asset = _asset;
|
||||
if (asset == null) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
@@ -39,29 +44,50 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||
|
||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||
final isSyncTrashTimeline = timelineOrigin == TimelineOrigin.syncTrash;
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
if (isInTrash && isOwner && asset.hasRemote && !isSyncTrashTimeline)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (isSyncTrashTimeline) ...[
|
||||
KeepOnDeviceActionButton(
|
||||
source: ActionSource.viewer,
|
||||
onResult: (result) {
|
||||
showKeepResultToast(context, result);
|
||||
_updateView(result, ref);
|
||||
},
|
||||
),
|
||||
MoveToTrashActionButton(
|
||||
source: ActionSource.viewer,
|
||||
onResult: (result) {
|
||||
showTrashResultToast(context, result);
|
||||
_updateView(result, ref);
|
||||
},
|
||||
),
|
||||
] else ...[
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (isOwner) ...[
|
||||
if (asset.isLocalOnly)
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
else if (asset.isTrashed)
|
||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
||||
else
|
||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (isOwner) ...[
|
||||
if (asset.isLocalOnly)
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
else if (asset.isTrashed)
|
||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
||||
else
|
||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -112,4 +138,15 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateView(ActionResult result, WidgetRef ref) {
|
||||
Future.delayed(Durations.extralong4, () {
|
||||
if (result.success) {
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
}
|
||||
if (ref.context.mounted) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -35,6 +36,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
||||
final isWaitingForTrashApproval = ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
|
||||
|
||||
final actionContext = ActionButtonContext(
|
||||
asset: asset,
|
||||
@@ -48,6 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
source: ActionSource.viewer,
|
||||
isCasting: isCasting,
|
||||
timelineOrigin: timelineOrigin,
|
||||
isWaitingForTrashApproval: isWaitingForTrashApproval,
|
||||
);
|
||||
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
@@ -14,6 +15,8 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -47,6 +50,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final isWaitingForSyncApproval =
|
||||
ref.read(timelineServiceProvider).origin == TimelineOrigin.syncTrash ||
|
||||
ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
@@ -63,9 +70,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
},
|
||||
),
|
||||
|
||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||
if (asset.hasRemote && isOwner && !asset.isFavorite && !isWaitingForSyncApproval)
|
||||
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||
if (asset.hasRemote && isOwner && asset.isFavorite && !isWaitingForSyncApproval)
|
||||
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
|
||||
ViewerKebabMenu(originalTheme: originalTheme),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_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';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
class BackupToggleButton extends ConsumerStatefulWidget {
|
||||
final VoidCallback onStart;
|
||||
@@ -31,7 +31,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
end: 1,
|
||||
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
|
||||
|
||||
_isEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
_isEnabled = ref.read(metadataProvider).appConfig.backup.enabled;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -41,7 +41,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
}
|
||||
|
||||
Future<void> _onToggle(bool value) async {
|
||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.enableBackup, value);
|
||||
await ref.read(metadataProvider).write(MetadataKey.backupEnabled, value);
|
||||
|
||||
setState(() {
|
||||
_isEnabled = value;
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
@@ -26,6 +27,7 @@ import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.d
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -57,6 +59,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
||||
final tagsEnabled = ref.watch(
|
||||
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
||||
);
|
||||
|
||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||
final selectedAssets = multiselect.selectedAssets;
|
||||
@@ -114,6 +119,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const FavoriteActionButton(source: ActionSource.timeline),
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
|
||||
|
||||
class TrashSyncBottomBar extends ConsumerWidget {
|
||||
const TrashSyncBottomBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Container(
|
||||
color: context.themeData.canvasColor,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
KeepOnDeviceActionButton(
|
||||
source: ActionSource.timeline,
|
||||
onResult: (result) => showKeepResultToast(context, result),
|
||||
),
|
||||
MoveToTrashActionButton(
|
||||
source: ActionSource.timeline,
|
||||
onResult: (result) => showTrashResultToast(context, result),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,9 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
},
|
||||
flightShuttleBuilder: (context, animation, direction, from, to) {
|
||||
void animationStatusListener(AnimationStatus status) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse;
|
||||
if (_hideIndicators != heroInFlight) {
|
||||
setState(() => _hideIndicators = heroInFlight);
|
||||
|
||||
@@ -242,7 +242,11 @@ class _AssetTileWidget extends ConsumerWidget {
|
||||
return false;
|
||||
}
|
||||
|
||||
return lockSelectionAssets.contains(asset);
|
||||
// Iterate with `==` instead of `Set.contains` because `RemoteAsset.hashCode`
|
||||
// includes `localId` while `==` does not — so the same server asset can
|
||||
// hash to a different bucket when its `localId` differs (e.g., album-fetched
|
||||
// copy has localId=null, merged-timeline copy has it populated).
|
||||
return lockSelectionAssets.any((a) => a == asset);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -64,36 +64,32 @@ class Timeline extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -379,121 +375,126 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
},
|
||||
child: asyncSegments.widgetWhen(
|
||||
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||
? 200
|
||||
: 0;
|
||||
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
||||
child: PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: asyncSegments.widgetWhen(
|
||||
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||
? 200
|
||||
: 0;
|
||||
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
||||
|
||||
const bottomSheetOpenModifier = 120.0;
|
||||
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
|
||||
const bottomSheetOpenModifier = 120.0;
|
||||
final contentBottomPadding =
|
||||
context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
|
||||
|
||||
final grid = CustomScrollView(
|
||||
primary: true,
|
||||
physics: _scrollPhysics,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
if (index >= childCount) {
|
||||
return null;
|
||||
}
|
||||
final segment = segments.findByIndex(index);
|
||||
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
// We add repaint boundary around tiles, so skip the auto boundaries
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
),
|
||||
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
|
||||
],
|
||||
);
|
||||
final grid = CustomScrollView(
|
||||
primary: true,
|
||||
physics: _scrollPhysics,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
if (index >= childCount) {
|
||||
return null;
|
||||
}
|
||||
final segment = segments.findByIndex(index);
|
||||
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
// We add repaint boundary around tiles, so skip the auto boundaries
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
),
|
||||
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
|
||||
],
|
||||
);
|
||||
|
||||
final Widget timeline;
|
||||
if (widget.withScrubber) {
|
||||
timeline = Scrubber(
|
||||
snapToMonth: widget.snapToMonth,
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: topPadding,
|
||||
bottomPadding: scrubberBottomPadding,
|
||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||
hasAppBar: widget.appBar != null,
|
||||
child: grid,
|
||||
);
|
||||
} else {
|
||||
timeline = grid;
|
||||
}
|
||||
final Widget timeline;
|
||||
if (widget.withScrubber) {
|
||||
timeline = Scrubber(
|
||||
snapToMonth: widget.snapToMonth,
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: topPadding,
|
||||
bottomPadding: scrubberBottomPadding,
|
||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||
hasAppBar: widget.appBar != null,
|
||||
child: grid,
|
||||
);
|
||||
} else {
|
||||
timeline = grid;
|
||||
}
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
return RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
if (newPerRow != _perRow) {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||
onScroll: _dragScroll,
|
||||
onScrollStart: () {
|
||||
// Minimize the bottom sheet when drag selection starts
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
timeline,
|
||||
if (isBottomWidgetVisible)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||
onScroll: _dragScroll,
|
||||
onScrollStart: () {
|
||||
// Minimize the bottom sheet when drag selection starts
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
timeline,
|
||||
if (isBottomWidgetVisible)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
],
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class PendingAlbumUpload {
|
||||
final LocalAsset asset;
|
||||
final double progress;
|
||||
final bool failed;
|
||||
|
||||
const PendingAlbumUpload({required this.asset, this.progress = 0.0, this.failed = false});
|
||||
|
||||
PendingAlbumUpload copyWith({double? progress, bool? failed}) =>
|
||||
PendingAlbumUpload(asset: asset, progress: progress ?? this.progress, failed: failed ?? this.failed);
|
||||
}
|
||||
|
||||
class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier<List<PendingAlbumUpload>, String> {
|
||||
KeepAliveLink? _keepAliveLink;
|
||||
|
||||
@override
|
||||
List<PendingAlbumUpload> build(String albumId) {
|
||||
ref.onDispose(() {
|
||||
_keepAliveLink?.close();
|
||||
_keepAliveLink = null;
|
||||
});
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
void enqueue(Iterable<LocalAsset> assets) {
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final existingIds = state.map((e) => e.asset.id).toSet();
|
||||
final additions = assets.where((a) => !existingIds.contains(a.id)).map((a) => PendingAlbumUpload(asset: a));
|
||||
state = [...state, ...additions];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void updateProgress(String localAssetId, double progress) {
|
||||
state = [
|
||||
for (final entry in state)
|
||||
if (entry.asset.id == localAssetId) entry.copyWith(progress: progress, failed: false) else entry,
|
||||
];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void markFailed(String localAssetId) {
|
||||
state = [
|
||||
for (final entry in state)
|
||||
if (entry.asset.id == localAssetId) entry.copyWith(failed: true) else entry,
|
||||
];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void markAllFailed() {
|
||||
state = [for (final entry in state) entry.copyWith(failed: true)];
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void remove(String localAssetId) {
|
||||
state = state.where((e) => e.asset.id != localAssetId).toList();
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void clearFailed() {
|
||||
state = state.where((e) => !e.failed).toList();
|
||||
_syncKeepAlive();
|
||||
}
|
||||
|
||||
void _syncKeepAlive() {
|
||||
if (state.isEmpty) {
|
||||
_keepAliveLink?.close();
|
||||
_keepAliveLink = null;
|
||||
} else {
|
||||
_keepAliveLink ??= ref.keepAlive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final pendingAlbumUploadsProvider = NotifierProvider.autoDispose
|
||||
.family<AlbumPendingUploadsNotifier, List<PendingAlbumUpload>, String>(AlbumPendingUploadsNotifier.new);
|
||||
@@ -5,16 +5,15 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden }
|
||||
@@ -108,7 +107,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final backgroundManager = _ref.read(backgroundSyncProvider);
|
||||
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
||||
final isAlbumLinkedSyncEnable = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
|
||||
try {
|
||||
bool syncSuccess = false;
|
||||
@@ -138,7 +137,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
}
|
||||
|
||||
Future<void> _resumeBackup() async {
|
||||
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
final isEnableBackup = _ref.read(metadataProvider).appConfig.backup.enabled;
|
||||
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
@@ -2,3 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
final appSettingsServiceProvider = Provider((_) => const AppSettingsService());
|
||||
|
||||
final appSettingStreamProvider = StreamProvider.family.autoDispose<bool, AppSettingsEnum<bool>>((ref, setting) {
|
||||
final service = ref.watch(appSettingsServiceProvider);
|
||||
return service.watchSetting(setting);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
@@ -8,6 +11,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.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';
|
||||
@@ -126,7 +130,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
await _apiService.updateHeaders();
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final customHeaders = Store.tryGet(StoreKey.customHeaders);
|
||||
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
|
||||
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
|
||||
|
||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||
@@ -174,19 +179,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<void> saveWifiName(String wifiName) async {
|
||||
await Store.put(StoreKey.preferredWifiName, wifiName);
|
||||
await _ref.read(metadataProvider).write(MetadataKey.networkPreferredWifiName, wifiName);
|
||||
}
|
||||
|
||||
Future<void> saveLocalEndpoint(String url) async {
|
||||
await Store.put(StoreKey.localEndpoint, url);
|
||||
await _ref.read(metadataProvider).write(MetadataKey.networkLocalEndpoint, url);
|
||||
}
|
||||
|
||||
String? getSavedWifiName() {
|
||||
return Store.tryGet(StoreKey.preferredWifiName);
|
||||
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
|
||||
}
|
||||
|
||||
String? getSavedLocalEndpoint() {
|
||||
return Store.tryGet(StoreKey.localEndpoint);
|
||||
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
|
||||
}
|
||||
|
||||
/// Returns the current server endpoint (with /api) URL from the store
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
@@ -353,6 +354,23 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
final count = await _service.tagAssets(ids, context);
|
||||
if (count == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ref.invalidate(tagProvider);
|
||||
return ActionResult(count: count, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to tag assets', error, stack);
|
||||
ref.invalidate(tagProvider);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
try {
|
||||
@@ -532,6 +550,21 @@ class ActionNotifier extends Notifier<void> {
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> resolveRemoteTrash(ActionSource source, {required bool keep}) async {
|
||||
final selectedLocalIds = _getAssets(source).map((a) => a.localId).nonNulls.toSet();
|
||||
_logger.info('resolveRemoteTrash, selectedLocalIds: $selectedLocalIds, keep: $keep');
|
||||
if (selectedLocalIds.isEmpty) {
|
||||
return const ActionResult(count: 0, success: false, error: 'Failed to select asset(s)');
|
||||
}
|
||||
try {
|
||||
final result = await _service.resolveRemoteTrash(selectedLocalIds, keep: keep);
|
||||
return ActionResult(count: result.displayCount, success: result.success);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to ${keep ? 'keep' : 'trash'} assets', error, stack);
|
||||
return ActionResult(count: selectedLocalIds.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<RemoteAsset> {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
|
||||
final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
|
||||
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
|
||||
@@ -33,7 +34,11 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
|
||||
);
|
||||
|
||||
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
|
||||
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
|
||||
(ref) => RemoteAlbumService(
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(foregroundUploadServiceProvider),
|
||||
),
|
||||
dependencies: [remoteAlbumRepository],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
@@ -6,8 +8,10 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class RemoteAlbumState {
|
||||
@@ -105,6 +109,46 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an album from a heterogeneous asset selection. Already-remote
|
||||
/// assets seed the album immediately; local-only assets are uploaded in the
|
||||
/// background and linked one-by-one as each upload completes.
|
||||
Future<RemoteAlbum?> createAlbumWithAssets({
|
||||
required String title,
|
||||
String? description,
|
||||
Iterable<BaseAsset> assets = const [],
|
||||
}) async {
|
||||
try {
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
throw Exception('User not logged in');
|
||||
}
|
||||
|
||||
final candidates = RemoteAlbumService.categorizeCandidates(assets);
|
||||
final album = await _remoteAlbumService.createAlbum(
|
||||
title: title,
|
||||
owner: currentUser,
|
||||
description: description,
|
||||
assetIds: candidates.remoteAssetIds,
|
||||
);
|
||||
|
||||
state = state.copyWith(albums: [...state.albums, album]);
|
||||
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
unawaited(
|
||||
addAssetsToAlbum(
|
||||
album.id,
|
||||
candidates.localAssetsToUpload,
|
||||
).then<void>((_) {}).catchError((Object _, StackTrace _) {}),
|
||||
);
|
||||
}
|
||||
|
||||
return album;
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to create album with assets', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<RemoteAlbum?> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
@@ -155,8 +199,65 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
return _remoteAlbumService.getAssets(albumId);
|
||||
}
|
||||
|
||||
Future<int> addAssets(String albumId, List<String> assetIds) {
|
||||
return _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
|
||||
Future<int> addAssets(String albumId, List<String> assetIds) async {
|
||||
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
|
||||
if (added > 0) {
|
||||
await _refreshAlbumInState(albumId);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/// Adds a heterogeneous asset selection to an album. Already-remote assets
|
||||
/// are linked immediately; local-only assets are queued in
|
||||
/// [pendingAlbumUploadsProvider] (so the album page can show them with
|
||||
/// progress indicators), uploaded, and linked one-by-one as each finishes.
|
||||
Future<int> addAssetsToAlbum(String albumId, Iterable<BaseAsset> assets) async {
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
throw Exception('User not logged in');
|
||||
}
|
||||
|
||||
final candidates = RemoteAlbumService.categorizeCandidates(assets);
|
||||
final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier);
|
||||
pendingNotifier.enqueue(candidates.localAssetsToUpload);
|
||||
|
||||
try {
|
||||
final added = await _remoteAlbumService.addAssetsToAlbum(
|
||||
albumId: albumId,
|
||||
uploader: currentUser,
|
||||
candidates: candidates,
|
||||
uploadCallbacks: UploadCallbacks(
|
||||
onProgress: (localAssetId, _, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
pendingNotifier.updateProgress(localAssetId, progress);
|
||||
},
|
||||
onSuccess: (localAssetId, _) => pendingNotifier.remove(localAssetId),
|
||||
onError: (localAssetId, _) => pendingNotifier.markFailed(localAssetId),
|
||||
),
|
||||
);
|
||||
if (added > 0) {
|
||||
await _refreshAlbumInState(albumId);
|
||||
}
|
||||
return added;
|
||||
} catch (error, stack) {
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
pendingNotifier.markAllFailed();
|
||||
}
|
||||
_logger.severe('Failed to add assets to album $albumId', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads a single album from the local DB and replaces it in [state] so
|
||||
/// that views bound to the album list (counts, thumbnails) reflect the
|
||||
/// latest junction-table changes without a full `refresh()`.
|
||||
Future<void> _refreshAlbumInState(String albumId) async {
|
||||
final updated = await _remoteAlbumService.get(albumId);
|
||||
if (updated == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(albums: state.albums.map((album) => album.id == albumId ? updated : album).toList());
|
||||
}
|
||||
|
||||
Future<void> addUsers(String albumId, List<String> userIds) {
|
||||
|
||||
@@ -11,8 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.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/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
|
||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||
|
||||
@@ -20,10 +19,7 @@ final syncStreamServiceProvider = Provider(
|
||||
(ref) => SyncStreamService(
|
||||
syncApiRepository: ref.watch(syncApiRepositoryProvider),
|
||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
|
||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||
api: ref.watch(apiServiceProvider),
|
||||
cancelChecker: ref.watch(cancellationProvider),
|
||||
@@ -39,8 +35,7 @@ final localSyncServiceProvider = Provider(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
|
||||
class TagNotifier extends AsyncNotifier<Set<Tag>> {
|
||||
@override
|
||||
Future<Set<Tag>> build() async {
|
||||
final repo = ref.read(tagsApiRepositoryProvider);
|
||||
final allTags = await repo.getAllTags();
|
||||
if (allTags == null) {
|
||||
return {};
|
||||
}
|
||||
return allTags.map((t) => Tag.fromDto(t)).toSet();
|
||||
return ref.watch(tagServiceProvider).getAllTags();
|
||||
}
|
||||
|
||||
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
|
||||
return ref.read(tagServiceProvider).bulkTagAssets(assetIds, tagIds);
|
||||
}
|
||||
|
||||
Future<List<Tag>> upsertTags(List<String> tags) async {
|
||||
final upsertedTags = await ref.read(tagServiceProvider).upsertTags(tags);
|
||||
|
||||
state = AsyncValue.data({...?state.valueOrNull, ...upsertedTags});
|
||||
return upsertedTags;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
typedef TrashedAssetsCount = ({int total, int hashed});
|
||||
|
||||
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>((ref) {
|
||||
return DriftTrashSyncRepository(
|
||||
ref.watch(driftProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
|
||||
final repo = ref.watch(trashedLocalAssetRepository);
|
||||
final total$ = repo.watchCount();
|
||||
final hashed$ = repo.watchHashedCount();
|
||||
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
|
||||
});
|
||||
|
||||
final outOfSyncAssetsCountProvider = StreamProvider<int>((ref) {
|
||||
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
|
||||
final repo = ref.watch(trashSyncRepositoryProvider);
|
||||
return enabledReviewMode.when(
|
||||
data: (enabled) => enabled ? repo.watchPendingReviewCount() : Stream<int>.value(0),
|
||||
loading: () => Stream<int>.value(0),
|
||||
error: (_, __) => Stream<int>.value(0),
|
||||
);
|
||||
});
|
||||
|
||||
final isWaitingForTrashApprovalProvider = StreamProvider.family<bool, String?>((ref, checksum) {
|
||||
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
|
||||
final repo = ref.watch(trashSyncRepositoryProvider);
|
||||
return enabledReviewMode.when(
|
||||
data: (enabled) => enabled && checksum != null ? repo.watchIsAssetPendingByChecksum(checksum) : Stream.value(false),
|
||||
loading: () => Stream.value(false),
|
||||
error: (_, __) => Stream.value(false),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
@@ -192,7 +193,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
|
||||
final isSyncAlbumEnabled = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
try {
|
||||
unawaited(
|
||||
_ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) {
|
||||
@@ -213,7 +214,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
|
||||
final isSyncAlbumEnabled = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
try {
|
||||
unawaited(
|
||||
_ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) {
|
||||
|
||||
@@ -8,19 +8,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
||||
);
|
||||
|
||||
class AssetMediaRepository {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._assetApiRepository);
|
||||
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -45,6 +50,54 @@ class AssetMediaRepository {
|
||||
return PhotoManager.editor.deleteWithIds(ids);
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
try {
|
||||
return await _nativeSyncApi.requestManageMediaPermission();
|
||||
} catch (e, s) {
|
||||
_log.warning('Error requesting manage media permission', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
try {
|
||||
return await _nativeSyncApi.hasManageMediaPermission();
|
||||
} catch (e, s) {
|
||||
_log.warning('Error requesting manage media permission state', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
try {
|
||||
return await _nativeSyncApi.manageMediaPermission();
|
||||
} catch (e, s) {
|
||||
_log.warning('Error requesting manage media permission settings', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
|
||||
try {
|
||||
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
|
||||
} catch (e, s) {
|
||||
_log.warning('Error restore file from trash by Id', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||
final restoredIds = <String>[];
|
||||
for (final asset in assets) {
|
||||
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
|
||||
final result = await _restoreFromTrashById(asset.id, asset.type.index);
|
||||
if (result) {
|
||||
restoredIds.add(asset.id);
|
||||
}
|
||||
}
|
||||
return restoredIds;
|
||||
}
|
||||
|
||||
Future<AssetEntity?> get(String id) async {
|
||||
final entity = await AssetEntity.fromId(id);
|
||||
return entity;
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) => AuthRepository(ref.watch(driftProvider)));
|
||||
final authRepositoryProvider = Provider<AuthRepository>(
|
||||
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
|
||||
);
|
||||
|
||||
class AuthRepository {
|
||||
final Drift _drift;
|
||||
final MetadataRepository _metadata;
|
||||
|
||||
const AuthRepository(this._drift);
|
||||
const AuthRepository(this._drift, this._metadata);
|
||||
|
||||
Future<void> clearLocalData() async {
|
||||
await SyncStreamRepository(_drift).reset();
|
||||
}
|
||||
|
||||
bool getEndpointSwitchingFeature() {
|
||||
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
|
||||
return _metadata.systemConfig.network.autoEndpointSwitching;
|
||||
}
|
||||
|
||||
String? getPreferredWifiName() {
|
||||
return Store.tryGet(StoreKey.preferredWifiName);
|
||||
return _metadata.systemConfig.network.preferredWifiName;
|
||||
}
|
||||
|
||||
String? getLocalEndpoint() {
|
||||
return Store.tryGet(StoreKey.localEndpoint);
|
||||
return _metadata.systemConfig.network.localEndpoint;
|
||||
}
|
||||
|
||||
List<AuxilaryEndpoint> getExternalEndpointList() {
|
||||
final jsonString = Store.tryGet(StoreKey.externalEndpointList);
|
||||
|
||||
if (jsonString == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> jsonList = jsonDecode(jsonString);
|
||||
final endpointList = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
|
||||
|
||||
return endpointList;
|
||||
return _metadata.systemConfig.network.externalEndpointList
|
||||
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/services/local_files_manager.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final localFilesManagerRepositoryProvider = Provider(
|
||||
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
|
||||
);
|
||||
|
||||
class LocalFilesManagerRepository {
|
||||
LocalFilesManagerRepository(this._service);
|
||||
|
||||
final Logger _logger = Logger('LocalFilesManagerRepo');
|
||||
final LocalFilesManagerService _service;
|
||||
|
||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
return await _service.moveToTrash(mediaUrls);
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
return await _service.restoreFromTrash(fileName, type);
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
return await _service.requestManageMediaPermission();
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
return await _service.hasManageMediaPermission();
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
return await _service.manageMediaPermission();
|
||||
}
|
||||
|
||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||
final restoredIds = <String>[];
|
||||
for (final asset in assets) {
|
||||
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
|
||||
try {
|
||||
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
|
||||
if (result) {
|
||||
restoredIds.add(asset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning("Restoring failure: $e");
|
||||
}
|
||||
}
|
||||
return restoredIds;
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,10 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_trash_sync_review.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
||||
@@ -162,6 +164,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftTrashRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftTrashSyncReviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
@@ -189,6 +192,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
RedirectRoute(path: '*', redirectTo: '/'),
|
||||
|
||||
@@ -1095,6 +1095,53 @@ class DriftSearchRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftSlideshowPage]
|
||||
class DriftSlideshowRoute extends PageRouteInfo<DriftSlideshowRouteArgs> {
|
||||
DriftSlideshowRoute({
|
||||
Key? key,
|
||||
required TimelineService timeline,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftSlideshowRoute.name,
|
||||
args: DriftSlideshowRouteArgs(key: key, timeline: timeline),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'DriftSlideshowRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftSlideshowRouteArgs>();
|
||||
return DriftSlideshowPage(key: args.key, timeline: args.timeline);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftSlideshowRouteArgs {
|
||||
const DriftSlideshowRouteArgs({this.key, required this.timeline});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final TimelineService timeline;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! DriftSlideshowRouteArgs) return false;
|
||||
return key == other.key && timeline == other.timeline;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode ^ timeline.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftTrashPage]
|
||||
class DriftTrashRoute extends PageRouteInfo<void> {
|
||||
@@ -1111,6 +1158,22 @@ class DriftTrashRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftTrashSyncReviewPage]
|
||||
class DriftTrashSyncReviewRoute extends PageRouteInfo<void> {
|
||||
const DriftTrashSyncReviewRoute({List<PageRouteInfo>? children})
|
||||
: super(DriftTrashSyncReviewRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'DriftTrashSyncReviewRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const DriftTrashSyncReviewPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftUploadDetailPage]
|
||||
class DriftUploadDetailRoute extends PageRouteInfo<void> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user