From 9b58d5663a597e273675576cb1699b3888560862 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 23 Oct 2025 15:14:01 +0100 Subject: [PATCH 001/105] feat: support database dumps for pg18 (#23186) --- server/src/services/backup.service.spec.ts | 3 ++- server/src/services/backup.service.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index ad60e30425..8aa20aa868 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -209,6 +209,7 @@ describe(BackupService.name, () => { ${'15.3.3'} | ${15} ${'16.4.2'} | ${16} ${'17.15.1'} | ${17} + ${'18.0.0'} | ${18} `( `should use pg_dumpall $expectedVersion with postgres version $postgresVersion`, async ({ postgresVersion, expectedVersion }) => { @@ -224,7 +225,7 @@ describe(BackupService.name, () => { it.each` postgresVersion ${'13.99.99'} - ${'18.0.0'} + ${'19.0.0'} `(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => { mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); const result = await sut.handleBackupDatabase(); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 3d99b6e522..6f8cc0e34a 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -103,7 +103,7 @@ export class BackupService extends BaseService { const databaseSemver = semver.coerce(databaseVersion); const databaseMajorVersion = databaseSemver?.major; - if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <18.0.0')) { + if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <19.0.0')) { this.logger.error(`Database Backup Failure: Unsupported PostgreSQL version: ${databaseVersion}`); return JobStatus.Failed; } From 47436ad0ceacc7d56369a1aec4ba36ce8d3c5a6e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 23 Oct 2025 14:57:19 -0500 Subject: [PATCH 002/105] feat: GHA for iOS release flow (#23196) --- .github/workflows/build-mobile.yml | 106 +++++++++++++++++++++++++++++ mobile/ios/Gemfile | 1 + mobile/ios/fastlane/Fastfile | 49 +++++++++++++ 3 files changed, 156 insertions(+) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 8750556c71..b678fa3ada 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -154,3 +154,109 @@ jobs: mobile/android/.gradle mobile/.dart_tool key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }} + + build-sign-ios: + name: Build and sign iOS + needs: pre-job + permissions: + contents: read + # Run on main branch or workflow_dispatch + if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true && github.ref == 'refs/heads/main' }} + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version-file: ./mobile/pubspec.yaml + cache: true + + - name: Install Flutter dependencies + working-directory: ./mobile + run: flutter pub get + + - name: Generate translation files + run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart + working-directory: ./mobile + + - name: Generate platform APIs + run: make pigeon + working-directory: ./mobile + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + working-directory: ./mobile/ios + + - name: Install Fastlane + run: | + cd mobile/ios + gem install bundler + bundle install + + - name: Create API Key JSON + env: + API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY }} + working-directory: ./mobile/ios + run: | + mkdir -p ~/.appstoreconnect/private_keys + echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8 + cat > api_key.json << EOF + { + "key_id": "${API_KEY_ID}", + "issuer_id": "${API_KEY_ISSUER_ID}", + "key": "$(cat ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8)", + "duration": 1200, + "in_house": false + } + EOF + + - name: Import Certificate and Provisioning Profile + env: + IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + working-directory: ./mobile/ios + run: | + echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12 + echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision + + - name: Create keychain + env: + KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + run: | + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + + - name: Build and deploy to TestFlight + env: + FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + KEYCHAIN_NAME: build.keychain + KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + working-directory: ./mobile/ios + run: bundle exec fastlane release_ci + + - name: Clean up keychain + if: always() + run: | + security delete-keychain build.keychain || true + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + with: + name: ios-release-ipa + path: mobile/ios/Runner.ipa diff --git a/mobile/ios/Gemfile b/mobile/ios/Gemfile index 7a118b49be..bb94aef518 100644 --- a/mobile/ios/Gemfile +++ b/mobile/ios/Gemfile @@ -1,3 +1,4 @@ source "https://rubygems.org" gem "fastlane" +gem "cocoapods" \ No newline at end of file diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 57c853e751..04c2087e3d 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -16,6 +16,55 @@ default_platform(:ios) platform :ios do + desc "iOS Release to TestFlight" + lane :release_ci do + # Setup CI environment + setup_ci + + # Import certificate and provisioning profile + import_certificate( + certificate_path: "certificate.p12", + certificate_password: ENV["IOS_CERTIFICATE_PASSWORD"], + keychain_name: ENV["KEYCHAIN_NAME"], + keychain_password: ENV["KEYCHAIN_PASSWORD"] + ) + + # Install provisioning profile + install_provisioning_profile(path: "profile.mobileprovision") + + # Configure code signing + update_code_signing_settings( + use_automatic_signing: false, + path: "./Runner.xcodeproj", + team_id: ENV["FASTLANE_TEAM_ID"], + profile_name: "app.alextran.immich AppStore" + ) + + # Increment build number + increment_build_number( + build_number: latest_testflight_build_number + 1, + xcodeproj: "./Runner.xcodeproj" + ) + + # Build the app + build_app( + scheme: "Runner", + workspace: "Runner.xcworkspace", + export_method: "app-store", + export_options: { + provisioningProfiles: { + "app.alextran.immich" => "app.alextran.immich AppStore" + } + } + ) + + # Upload to TestFlight + upload_to_testflight( + api_key_path: "api_key.json", + skip_waiting_for_build_processing: true + ) + end + desc "iOS Release" lane :release do enable_automatic_code_signing( From 3c8df55986aa041b2148a7bc5727e7e0cbf694a6 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 23 Oct 2025 22:19:44 +0200 Subject: [PATCH 003/105] fix: add bundle platform arm64-darwin-23 (#23197) --- mobile/ios/Gemfile.lock | 80 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/mobile/ios/Gemfile.lock b/mobile/ios/Gemfile.lock index 218b8c1355..e3e07181e6 100644 --- a/mobile/ios/Gemfile.lock +++ b/mobile/ios/Gemfile.lock @@ -5,8 +5,23 @@ GEM base64 nkf rexml + activesupport (7.2.2.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) @@ -27,17 +42,62 @@ GEM aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) + benchmark (0.5.0) + bigdecimal (3.3.1) claide (1.1.0) + cocoapods (1.15.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.15.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.15.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) + drb (2.2.3) emoji_regex (3.2.3) + escape (0.0.4) + ethon (0.15.0) + ffi (>= 1.15.0) excon (0.110.0) faraday (1.10.3) faraday-em_http (~> 1.0) @@ -107,6 +167,11 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + fourflusher (2.3.1) + fuzzy_match (2.0.4) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -148,16 +213,23 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) httpclient (2.8.3) + i18n (1.14.7) + concurrent-ruby (~> 1.0) jmespath (1.6.2) json (2.7.2) jwt (2.8.1) base64 + logger (1.7.0) mini_magick (4.12.0) mini_mime (1.1.5) + minitest (5.26.0) + molinillo (0.8.0) multi_json (1.15.0) multipart-post (2.4.1) nanaimo (0.3.0) + nap (1.1.0) naturally (2.2.1) + netrc (0.11.0) nkf (0.2.0) optparse (0.1.1) os (1.1.4) @@ -172,8 +244,10 @@ GEM rexml (3.3.6) strscan rouge (2.0.7) + ruby-macho (2.5.1) ruby2_keywords (0.0.5) rubyzip (2.3.2) + securerandom (0.4.1) security (0.1.3) signet (0.19.0) addressable (~> 2.8) @@ -192,6 +266,10 @@ GEM tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) + typhoeus (1.5.0) + ethon (>= 0.9.0, < 0.16.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) uber (0.1.0) unicode-display_width (1.8.0) word_wrap (1.0.0) @@ -208,10 +286,12 @@ GEM xcpretty (~> 0.2, >= 0.0.7) PLATFORMS + arm64-darwin-23 x86_64-darwin-21 x86_64-linux DEPENDENCIES + cocoapods fastlane BUNDLED WITH From f8afef0f9d2863a6c34b2e9eceb590b889c7ad5e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 23 Oct 2025 15:35:43 -0500 Subject: [PATCH 004/105] chore: gha ios release | take 3 (#23199) * chore: gha ios release | take 3 * chore: gha ios release | take 3 --- .github/workflows/build-mobile.yml | 5 +- mobile/ios/.gitignore | 1 + mobile/ios/Gemfile.lock | 298 ----------------------------- 3 files changed, 4 insertions(+), 300 deletions(-) delete mode 100644 mobile/ios/Gemfile.lock diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index b678fa3ada..7c781f4b3f 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -58,7 +58,8 @@ jobs: permissions: contents: read # Skip when PR from a fork - if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }} + # if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }} + if: ${{ false }} runs-on: mich steps: @@ -194,13 +195,13 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.2' - bundler-cache: true working-directory: ./mobile/ios - name: Install Fastlane run: | cd mobile/ios gem install bundler + bundle config set --local path 'vendor/bundle' bundle install - name: Create API Key JSON diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index e32cadbf68..f1a46a2fef 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -33,3 +33,4 @@ Runner/GeneratedPluginRegistrant.* !default.perspectivev3 fastlane/report.xml +Gemfile.lock \ No newline at end of file diff --git a/mobile/ios/Gemfile.lock b/mobile/ios/Gemfile.lock deleted file mode 100644 index e3e07181e6..0000000000 --- a/mobile/ios/Gemfile.lock +++ /dev/null @@ -1,298 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - activesupport (7.2.2.2) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) - algoliasearch (1.27.5) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - artifactory (3.0.17) - atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.932.0) - aws-sdk-core (3.196.1) - aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) - jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.81.0) - aws-sdk-core (~> 3, >= 3.193.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.151.0) - aws-sdk-core (~> 3, >= 3.194.0) - aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) - aws-eventstream (~> 1, >= 1.0.2) - babosa (1.0.4) - base64 (0.2.0) - benchmark (0.5.0) - bigdecimal (3.3.1) - claide (1.1.0) - cocoapods (1.15.2) - addressable (~> 2.8) - claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.15.2) - cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 2.1, < 3.0) - cocoapods-plugins (>= 1.0.0, < 2.0) - cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.6.0, < 2.0) - cocoapods-try (>= 1.1.0, < 2.0) - colored2 (~> 3.1) - escape (~> 0.0.4) - fourflusher (>= 2.3.0, < 3.0) - gh_inspector (~> 1.0) - molinillo (~> 0.8.0) - nap (~> 1.0) - ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.15.2) - activesupport (>= 5.0, < 8) - addressable (~> 2.8) - algoliasearch (~> 1.0) - concurrent-ruby (~> 1.1) - fuzzy_match (~> 2.0.4) - nap (~> 1.0) - netrc (~> 0.11) - public_suffix (~> 4.0) - typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.5) - cocoapods-downloader (2.1) - cocoapods-plugins (1.0.0) - nap - cocoapods-search (1.0.1) - cocoapods-trunk (1.6.0) - nap (>= 0.8, < 2.0) - netrc (~> 0.11) - cocoapods-try (1.2.0) - colored (1.2) - colored2 (3.1.2) - commander (4.6.0) - highline (~> 2.0.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) - declarative (0.0.20) - digest-crc (0.6.5) - rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20240107) - dotenv (2.8.1) - drb (2.2.3) - emoji_regex (3.2.3) - escape (0.0.4) - ethon (0.15.0) - ffi (>= 1.15.0) - excon (0.110.0) - faraday (1.10.3) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) - faraday (>= 0.8.0) - http-cookie (~> 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.1) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.0) - faraday (~> 1.0) - fastimage (2.3.1) - fastlane (2.214.0) - CFPropertyList (>= 2.3, < 4.0.0) - addressable (>= 2.8, < 3.0.0) - artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) - babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) - colored - commander (~> 4.6) - dotenv (>= 2.1.1, < 3.0.0) - emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) - fastimage (>= 2.1.0, < 3.0.0) - gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) - google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-storage (~> 1.31) - highline (~> 2.0) - json (< 3.0.0) - jwt (>= 2.1.0, < 3) - mini_magick (>= 4.9.4, < 5.0.0) - multipart-post (>= 2.0.0, < 3.0.0) - naturally (~> 2.2) - optparse (~> 0.1.1) - plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) - simctl (~> 1.6.3) - terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) - tty-screen (>= 0.6.3, < 1.0.0) - tty-spinner (>= 0.8.0, < 1.0.0) - word_wrap (~> 1.0.0) - xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) - ffi (1.17.2-arm64-darwin) - ffi (1.17.2-x86_64-darwin) - ffi (1.17.2-x86_64-linux-gnu) - fourflusher (2.3.1) - fuzzy_match (2.0.4) - gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) - addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) - mini_mime (~> 1.0) - representable (~> 3.0) - retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.31.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.0) - google-cloud-env (>= 1.0, < 3.a) - google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.4.0) - google-cloud-storage (1.47.0) - addressable (~> 2.8) - digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) - google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) - mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - multi_json (~> 1.11) - os (>= 0.9, < 2.0) - signet (>= 0.16, < 2.a) - highline (2.0.3) - http-cookie (1.0.5) - domain_name (~> 0.5) - httpclient (2.8.3) - i18n (1.14.7) - concurrent-ruby (~> 1.0) - jmespath (1.6.2) - json (2.7.2) - jwt (2.8.1) - base64 - logger (1.7.0) - mini_magick (4.12.0) - mini_mime (1.1.5) - minitest (5.26.0) - molinillo (0.8.0) - multi_json (1.15.0) - multipart-post (2.4.1) - nanaimo (0.3.0) - nap (1.1.0) - naturally (2.2.1) - netrc (0.11.0) - nkf (0.2.0) - optparse (0.1.1) - os (1.1.4) - plist (3.7.1) - public_suffix (4.0.7) - rake (13.2.1) - representable (3.2.0) - declarative (< 0.1.0) - trailblazer-option (>= 0.1.1, < 0.2.0) - uber (< 0.2.0) - retriable (3.1.2) - rexml (3.3.6) - strscan - rouge (2.0.7) - ruby-macho (2.5.1) - ruby2_keywords (0.0.5) - rubyzip (2.3.2) - securerandom (0.4.1) - security (0.1.3) - signet (0.19.0) - addressable (~> 2.8) - faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) - simctl (1.6.10) - CFPropertyList - naturally - strscan (3.1.0) - terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) - trailblazer-option (0.1.2) - tty-cursor (0.7.1) - tty-screen (0.8.2) - tty-spinner (0.9.3) - tty-cursor (~> 0.7) - typhoeus (1.5.0) - ethon (>= 0.9.0, < 0.16.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - uber (0.1.0) - unicode-display_width (1.8.0) - word_wrap (1.0.0) - xcodeproj (1.25.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (>= 3.3.2, < 4.0) - xcpretty (0.3.0) - rouge (~> 2.0.7) - xcpretty-travis-formatter (1.0.1) - xcpretty (~> 0.2, >= 0.0.7) - -PLATFORMS - arm64-darwin-23 - x86_64-darwin-21 - x86_64-linux - -DEPENDENCIES - cocoapods - fastlane - -BUNDLED WITH - 2.3.7 From 722dbfa11f9f03d33ee21eaa460feee9fcc4c7d9 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 23 Oct 2025 15:48:44 -0500 Subject: [PATCH 005/105] chore: gha ios release | take 3 (#23200) --- mobile/ios/fastlane/Fastfile | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 04c2087e3d..3bf0d8345f 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -21,6 +21,13 @@ platform :ios do # Setup CI environment setup_ci + # Load App Store Connect API Key + api_key = app_store_connect_api_key( + key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"], + issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], + key_filepath: "api_key.json" + ) + # Import certificate and provisioning profile import_certificate( certificate_path: "certificate.p12", @@ -42,7 +49,10 @@ platform :ios do # Increment build number increment_build_number( - build_number: latest_testflight_build_number + 1, + build_number: latest_testflight_build_number( + api_key: api_key, + app_identifier: "app.alextran.immich" + ) + 1, xcodeproj: "./Runner.xcodeproj" ) @@ -60,7 +70,7 @@ platform :ios do # Upload to TestFlight upload_to_testflight( - api_key_path: "api_key.json", + api_key: api_key, skip_waiting_for_build_processing: true ) end From d9a13dc8ac98f0e966eb2b8bb7bb3b7f52b852c3 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 23 Oct 2025 16:06:55 -0500 Subject: [PATCH 006/105] chore: gha ios release | take 4 (#23202) --- .github/workflows/build-mobile.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 7c781f4b3f..dca2024760 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -248,6 +248,8 @@ jobs: IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} KEYCHAIN_NAME: build.keychain KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} working-directory: ./mobile/ios run: bundle exec fastlane release_ci From 6164b027e22d9c40d63fb2d2b910d45a8eb64568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20=C5=81=C4=85giewka?= Date: Fri, 24 Oct 2025 05:29:18 +0200 Subject: [PATCH 007/105] chore(dep): bump ioredis to 5.8.2 (#23130) --- pnpm-lock.yaml | 12 ++++++------ server/package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb7549022b..98f4cd5540 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,8 +425,8 @@ importers: specifier: ^7.6.0 version: 7.14.0 ioredis: - specifier: ^5.3.2 - version: 5.8.1 + specifier: ^5.8.2 + version: 5.8.2 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -7302,8 +7302,8 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.8.1: - resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==} + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} engines: {node: '>=12.22.0'} ip-address@10.0.1: @@ -17212,7 +17212,7 @@ snapshots: bullmq@5.61.0: dependencies: cron-parser: 4.9.0 - ioredis: 5.8.1 + ioredis: 5.8.2 msgpackr: 1.11.5 node-abort-controller: 3.1.1 semver: 7.7.3 @@ -19600,7 +19600,7 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.8.1: + ioredis@5.8.2: dependencies: '@ioredis/commands': 1.4.0 cluster-key-slot: 1.1.2 diff --git a/server/package.json b/server/package.json index a7927e5ac6..694817591f 100644 --- a/server/package.json +++ b/server/package.json @@ -75,7 +75,7 @@ "geo-tz": "^8.0.0", "handlebars": "^4.7.8", "i18n-iso-countries": "^7.6.0", - "ioredis": "^5.3.2", + "ioredis": "^5.8.2", "js-yaml": "^4.1.0", "kysely": "0.28.2", "kysely-postgres-js": "^3.0.0", From 34bad1ce7186ef17afd922ae66818d327639ea00 Mon Sep 17 00:00:00 2001 From: Lauritz Tieste <84938977+Lauritz-Tieste@users.noreply.github.com> Date: Fri, 24 Oct 2025 05:36:49 +0200 Subject: [PATCH 008/105] feat: improvements of thumbnail animations (#20300) * feat: improve thumbnail border radius animation feat: remove thin border between image and image selection container feat: enhance selection icon in thumbnail image feat: add animated selection indicator for multiselect in thumbnail image feat: remove unnecessary widgets and variables style: format code fix: errors with formatting checks * chore: port to new timeline * chore: revert mobile/lib/widgets/asset_grid/thumbnail_image.dart --------- Co-authored-by: bwees --- .../widgets/images/thumbnail_tile.widget.dart | 63 +++++++++---------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 5359391261..c7628cb472 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -42,31 +42,23 @@ class ThumbnailTile extends ConsumerWidget { multiSelectProvider.select((multiselect) => multiselect.selectedAssets.contains(asset)), ); - final borderStyle = lockSelection - ? BoxDecoration( - color: context.colorScheme.surfaceContainerHighest, - border: Border.all(color: context.colorScheme.surfaceContainerHighest, width: 6), - ) - : isSelected - ? BoxDecoration( - color: assetContainerColor, - border: Border.all(color: assetContainerColor, width: 6), - ) - : const BoxDecoration(); - final bool storageIndicator = ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && showStorageIndicator; return Stack( children: [ + Container(color: lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor), AnimatedContainer( duration: Durations.short4, curve: Curves.decelerate, - decoration: borderStyle, - child: ClipRRect( - borderRadius: isSelected || lockSelection - ? const BorderRadius.all(Radius.circular(15.0)) - : BorderRadius.zero, + padding: EdgeInsets.all(isSelected || lockSelection ? 6 : 0), + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: (isSelected || lockSelection) ? 15.0 : 0.0), + duration: Durations.short4, + curve: Curves.decelerate, + builder: (context, value, child) { + return ClipRRect(borderRadius: BorderRadius.all(Radius.circular(value)), child: child); + }, child: Stack( children: [ Positioned.fill( @@ -116,29 +108,36 @@ class ThumbnailTile extends ConsumerWidget { ), ), ), - if (isSelected || lockSelection) - Padding( - padding: const EdgeInsets.all(3.0), - child: Align( - alignment: Alignment.topLeft, - child: _SelectionIndicator( - isSelected: isSelected, - isLocked: lockSelection, - color: lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor, + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: (isSelected || lockSelection) ? 1.0 : 0.0), + duration: Durations.short4, + curve: Curves.decelerate, + builder: (context, value, child) { + return Padding( + padding: EdgeInsets.all((isSelected || lockSelection) ? value * 3.0 : 3.0), + child: Align( + alignment: Alignment.topLeft, + child: Opacity( + opacity: (isSelected || lockSelection) ? 1 : value, + child: _SelectionIndicator( + isLocked: lockSelection, + color: lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor, + ), + ), ), - ), - ), + ); + }, + ), ], ); } } class _SelectionIndicator extends StatelessWidget { - final bool isSelected; final bool isLocked; final Color? color; - const _SelectionIndicator({required this.isSelected, required this.isLocked, this.color}); + const _SelectionIndicator({required this.isLocked, this.color}); @override Widget build(BuildContext context) { @@ -147,13 +146,11 @@ class _SelectionIndicator extends StatelessWidget { decoration: BoxDecoration(shape: BoxShape.circle, color: color), child: const Icon(Icons.check_circle_rounded, color: Colors.grey), ); - } else if (isSelected) { + } else { return DecoratedBox( decoration: BoxDecoration(shape: BoxShape.circle, color: color), child: Icon(Icons.check_circle_rounded, color: context.primaryColor), ); - } else { - return const Icon(Icons.circle_outlined, color: Colors.white); } } } From 719bf763e448a03bd314d2dffdd1a5934fb04b7c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:16:11 +0200 Subject: [PATCH 009/105] chore(deps): update prom/prometheus docker digest to 23031bf (#23111) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 78ba0653ac..22bb0f3444 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -83,7 +83,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996 + image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus From 0a6b2ad26edb0fefd3ddef9b7ae13774d195e207 Mon Sep 17 00:00:00 2001 From: Basharat Ahmad Khan <95114476+khanbasharat3a1@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:48:49 +0530 Subject: [PATCH 010/105] feat(web): reactively update shared link expiration (#22274) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- web/src/lib/modals/SharedLinkCreateModal.svelte | 4 ++-- .../routes/(user)/shared-links/[[id=id]]/+page.svelte | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/web/src/lib/modals/SharedLinkCreateModal.svelte b/web/src/lib/modals/SharedLinkCreateModal.svelte index 88f603c16b..3971b91fac 100644 --- a/web/src/lib/modals/SharedLinkCreateModal.svelte +++ b/web/src/lib/modals/SharedLinkCreateModal.svelte @@ -103,7 +103,7 @@ try { const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : null; - await updateSharedLink({ + const updatedLink = await updateSharedLink({ id: editingLink.id, sharedLinkEditDto: { description, @@ -121,7 +121,7 @@ message: $t('edited'), }); - onClose(); + onClose(updatedLink); } catch (error) { handleError(error, $t('errors.failed_to_edit_shared_link')); } diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index ea1c47f0f8..e37301300e 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -54,8 +54,15 @@ } }; - const handleEditDone = async () => { - await refresh(); + const handleEditDone = async (updatedLink?: SharedLinkResponseDto) => { + if (updatedLink) { + const index = sharedLinks.findIndex((link) => link.id === updatedLink.id); + if (index !== -1) { + sharedLinks[index] = updatedLink; + } + } else { + await refresh(); + } await goto(AppRoute.SHARED_LINKS); }; From 221e0ef02f98b7ca4cdb2f07be51f5b78a9e21b8 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 24 Oct 2025 19:26:49 +0530 Subject: [PATCH 011/105] fix: android skip posting hash response after detached from engine (#23192) fix: native cancellations for hashing Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../app/alextran/immich/MainActivity.kt | 9 ++++++ .../immich/background/BackgroundEngineLock.kt | 5 +++- .../immich/background/BackgroundWorker.kt | 3 ++ .../app/alextran/immich/core/ImmichPlugin.kt | 29 +++++++++++++++++++ .../alextran/immich/sync/MessagesImplBase.kt | 13 +++++---- 5 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/core/ImmichPlugin.kt diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 034f5ee72e..4383b3098d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -9,6 +9,7 @@ import app.alextran.immich.background.BackgroundWorkerFgHostApi import app.alextran.immich.background.BackgroundWorkerLockApi import app.alextran.immich.connectivity.ConnectivityApi import app.alextran.immich.connectivity.ConnectivityApiImpl +import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.images.ThumbnailApi import app.alextran.immich.images.ThumbnailsImpl import app.alextran.immich.sync.NativeSyncApi @@ -42,6 +43,14 @@ class MainActivity : FlutterFragmentActivity() { flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(HttpSSLOptionsPlugin()) flutterEngine.plugins.add(backgroundEngineLockImpl) + flutterEngine.plugins.add(nativeSyncApiImpl) + } + + fun cancelPlugins(flutterEngine: FlutterEngine) { + val nativeApi = + flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin? + ?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin? + nativeApi?.detachFromEngine() } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt index 504267a4e5..b11b53bcde 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt @@ -2,12 +2,13 @@ package app.alextran.immich.background import android.content.Context import android.util.Log +import app.alextran.immich.core.ImmichPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin import java.util.concurrent.atomic.AtomicInteger private const val TAG = "BackgroundEngineLock" -class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin { +class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, ImmichPlugin() { private val ctx: Context = context.applicationContext companion object { @@ -41,12 +42,14 @@ class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterP } override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + super.onAttachedToEngine(binding) checkAndEnforceBackgroundLock(binding.applicationContext) engineCount.incrementAndGet() Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount") } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + super.onDetachedFromEngine(binding) engineCount.decrementAndGet() Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount") } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt index e59cee2c16..7dce1f6edf 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -190,6 +190,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : private fun complete(success: Result) { Log.d(TAG, "About to complete BackupWorker with result: $success") isComplete = true + if (engine != null) { + MainActivity.cancelPlugins(engine!!) + } engine?.destroy() engine = null flutterApi = null diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/ImmichPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/ImmichPlugin.kt new file mode 100644 index 0000000000..4cc131b058 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/ImmichPlugin.kt @@ -0,0 +1,29 @@ +package app.alextran.immich.core + +import androidx.annotation.CallSuper +import io.flutter.embedding.engine.plugins.FlutterPlugin + +abstract class ImmichPlugin : FlutterPlugin { + private var detached: Boolean = false; + + @CallSuper + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + detached = false; + } + + fun detachFromEngine() { + detached = true + } + + @CallSuper + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + detachFromEngine() + } + + fun completeWhenActive(callback: (T) -> Unit, value: T) { + if (detached) { + return; + } + callback(value); + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 868f3c6cdd..0ea86bb10d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -7,6 +7,7 @@ import android.database.Cursor import android.provider.MediaStore import android.util.Base64 import androidx.core.database.getStringOrNull +import app.alextran.immich.core.ImmichPlugin import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,7 +28,7 @@ sealed class AssetResult { } @SuppressLint("InlinedApi") -open class NativeSyncApiImplBase(context: Context) { +open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { private val ctx: Context = context.applicationContext private var hashTask: Job? = null @@ -237,7 +238,7 @@ open class NativeSyncApiImplBase(context: Context) { callback: (Result>) -> Unit ) { if (assetIds.isEmpty()) { - callback(Result.success(emptyList())) + completeWhenActive(callback, Result.success(emptyList())) return } @@ -253,10 +254,10 @@ open class NativeSyncApiImplBase(context: Context) { } }.awaitAll() - callback(Result.success(results)) + completeWhenActive(callback, Result.success(results)) } catch (e: CancellationException) { - callback( - Result.failure( + completeWhenActive( + callback, Result.failure( FlutterError( HASHING_CANCELLED_CODE, "Hashing operation was cancelled", @@ -265,7 +266,7 @@ open class NativeSyncApiImplBase(context: Context) { ) ) } catch (e: Exception) { - callback(Result.failure(e)) + completeWhenActive(callback, Result.failure(e)) } } } From 2129f889f59833db239ffc9edf3c200e79ee1698 Mon Sep 17 00:00:00 2001 From: idubnori Date: Fri, 24 Oct 2025 23:02:56 +0900 Subject: [PATCH 012/105] feat: (mobile) open asset viewer from album activity page (#23182) * feat(mobile): open assetviewer via album activities page * adjust ui behavior: keep current asset & disable initial forcus * fix: Run 'make build' and 'make pigeon' --- .../lib/domain/services/timeline.service.dart | 1 + .../pages/drift_activities.page.dart | 2 +- .../album/drift_activity_text_field.dart | 4 --- .../providers/activity_service.provider.dart | 8 ++++- .../activity_service.provider.g.dart | 2 +- mobile/lib/services/activity.service.dart | 27 +++++++++++++- .../lib/widgets/activities/activity_tile.dart | 36 +++++++++++++------ 7 files changed, 61 insertions(+), 19 deletions(-) diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 85fc5fc55d..9537fe667a 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -33,6 +33,7 @@ enum TimelineOrigin { map, search, deepLink, + albumActivities, } class TimelineFactory { diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index 8e67d85884..d8f8799f7d 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -23,7 +23,7 @@ class DriftActivitiesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(currentRemoteAlbumProvider)!; - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; + final asset = ref.read(currentAssetNotifier) as RemoteAsset?; final user = ref.watch(currentUserProvider); final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); diff --git a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart index 86a0c80345..fe5c763ec5 100644 --- a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart +++ b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart @@ -36,10 +36,6 @@ class _DriftActivityTextFieldState extends ConsumerState inputController = TextEditingController(); inputFocusNode = FocusNode(); - if (!widget.isBottomSheet) { - inputFocusNode.requestFocus(); - } - inputFocusNode.addListener(() { if (inputFocusNode.hasFocus) { widget.onKeyboardFocus?.call(); diff --git a/mobile/lib/providers/activity_service.provider.dart b/mobile/lib/providers/activity_service.provider.dart index a7fd0715f8..f17617bced 100644 --- a/mobile/lib/providers/activity_service.provider.dart +++ b/mobile/lib/providers/activity_service.provider.dart @@ -1,4 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/services/activity.service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -6,4 +8,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity_service.provider.g.dart'; @riverpod -ActivityService activityService(Ref ref) => ActivityService(ref.watch(activityApiRepositoryProvider)); +ActivityService activityService(Ref ref) => ActivityService( + ref.watch(activityApiRepositoryProvider), + ref.watch(timelineFactoryProvider), + ref.watch(assetServiceProvider), +); diff --git a/mobile/lib/providers/activity_service.provider.g.dart b/mobile/lib/providers/activity_service.provider.g.dart index 2bf160c487..4641738fc4 100644 --- a/mobile/lib/providers/activity_service.provider.g.dart +++ b/mobile/lib/providers/activity_service.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity_service.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$activityServiceHash() => r'ce775779787588defe1e76406e09a9c109470310'; +String _$activityServiceHash() => r'3ce0eb33948138057cc63f07a7598047b99e7599'; /// See also [activityService]. @ProviderFor(activityService) diff --git a/mobile/lib/services/activity.service.dart b/mobile/lib/services/activity.service.dart index 1f09309947..09abde20e0 100644 --- a/mobile/lib/services/activity.service.dart +++ b/mobile/lib/services/activity.service.dart @@ -1,16 +1,24 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/errors.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/repositories/activity_api.repository.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:logging/logging.dart'; +import 'package:immich_mobile/entities/store.entity.dart' as immich_store; class ActivityService with ErrorLoggerMixin { final ActivityApiRepository _activityApiRepository; + final TimelineFactory _timelineFactory; + final AssetService _assetService; @override final Logger logger = Logger("ActivityService"); - ActivityService(this._activityApiRepository); + ActivityService(this._activityApiRepository, this._timelineFactory, this._assetService); Future> getAllActivities(String albumId, {String? assetId}) async { return logError( @@ -49,4 +57,21 @@ class ActivityService with ErrorLoggerMixin { errorMessage: "Failed to create $type for album $albumId", ); } + + Future buildAssetViewerRoute(String assetId, WidgetRef ref) async { + if (immich_store.Store.isBetaTimelineEnabled) { + final asset = await _assetService.getRemoteAsset(assetId); + if (asset == null) { + return null; + } + + AssetViewer.setAsset(ref, asset); + return AssetViewerRoute( + initialIndex: 0, + timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.albumActivities), + ); + } + + return null; + } } diff --git a/mobile/lib/widgets/activities/activity_tile.dart b/mobile/lib/widgets/activities/activity_tile.dart index e0cced0d22..6812d1b90c 100644 --- a/mobile/lib/widgets/activities/activity_tile.dart +++ b/mobile/lib/widgets/activities/activity_tile.dart @@ -1,8 +1,10 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/activity_service.provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @@ -21,6 +23,14 @@ class ActivityTile extends HookConsumerWidget { // currentAssetProvider will not be set until we open the gallery viewer final showAssetThumbnail = asset == null && activity.assetId != null && !isBottomSheet; + onTap() async { + final activityService = ref.read(activityServiceProvider); + final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref); + if (route != null) { + await context.pushRoute(route); + } + } + return ListTile( minVerticalPadding: 15, leading: isLike @@ -39,7 +49,7 @@ class ActivityTile extends HookConsumerWidget { ), // No subtitle for like, so center title titleAlignment: !isLike ? ListTileTitleAlignment.top : ListTileTitleAlignment.center, - trailing: showAssetThumbnail ? _ActivityAssetThumbnail(activity.assetId!) : null, + trailing: showAssetThumbnail ? _ActivityAssetThumbnail(activity.assetId!, onTap) : null, subtitle: !isLike ? Text(activity.comment!) : null, ); } @@ -78,22 +88,26 @@ class _ActivityTitle extends StatelessWidget { class _ActivityAssetThumbnail extends StatelessWidget { final String assetId; + final GestureTapCallback? onTap; - const _ActivityAssetThumbnail(this.assetId); + const _ActivityAssetThumbnail(this.assetId, this.onTap); @override Widget build(BuildContext context) { - return Container( - width: 40, - height: 30, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4)), - image: DecorationImage( - image: ImmichRemoteThumbnailProvider(assetId: assetId), - fit: BoxFit.cover, + return GestureDetector( + onTap: onTap, + child: Container( + width: 40, + height: 30, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + image: DecorationImage( + image: ImmichRemoteThumbnailProvider(assetId: assetId), + fit: BoxFit.cover, + ), ), + child: const SizedBox.shrink(), ), - child: const SizedBox.shrink(), ); } } From 7773d6d44f7fe9b8556de2d4624722bf7b47f4ce Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 24 Oct 2025 16:08:04 +0200 Subject: [PATCH 013/105] chore: update multi-runner-build-workflow (#23183) --- .github/workflows/docker.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 09a498607c..0c4dc9765c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -116,24 +116,23 @@ jobs: matrix: include: - device: cpu - tag-suffix: '' - device: cuda - tag-suffix: '-cuda' + suffixes: '-cuda' platforms: linux/amd64 - device: openvino - tag-suffix: '-openvino' + suffixes: '-openvino' platforms: linux/amd64 - device: armnn - tag-suffix: '-armnn' + suffixes: '-armnn' platforms: linux/arm64 - device: rknn - tag-suffix: '-rknn' + suffixes: '-rknn' platforms: linux/arm64 - device: rocm - tag-suffix: '-rocm' + suffixes: '-rocm' platforms: linux/amd64 runner-mapping: '{"linux/amd64": "mich"}' - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@946acac326940f8badf09ccf591d9cb345d6a689 # multi-runner-build-workflow-v0.2.1 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@a667ef0a5cf3ff1ff1e41be52d3fe326b24e3a00 # multi-runner-build-workflow-v1.1.3 permissions: contents: read actions: read @@ -147,7 +146,7 @@ jobs: dockerfile: machine-learning/Dockerfile platforms: ${{ matrix.platforms }} runner-mapping: ${{ matrix.runner-mapping }} - tag-suffix: ${{ matrix.tag-suffix }} + suffixes: ${{ matrix.suffixes }} dockerhub-push: ${{ github.event_name == 'release' }} build-args: | DEVICE=${{ matrix.device }} @@ -156,7 +155,7 @@ jobs: name: Build and Push Server needs: pre-job if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }} - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@946acac326940f8badf09ccf591d9cb345d6a689 # multi-runner-build-workflow-v0.2.1 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@a667ef0a5cf3ff1ff1e41be52d3fe326b24e3a00 # multi-runner-build-workflow-v1.1.3 permissions: contents: read actions: read From b91b8554730a45f6e1daea7b8faab8d7fc823590 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:21:41 +0200 Subject: [PATCH 014/105] chore(deps): update github-actions (major) (#22919) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-mobile.yml | 2 +- .github/workflows/cli.yml | 2 +- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/docs-build.yml | 2 +- .github/workflows/fix-format.yml | 2 +- .github/workflows/prepare-release.yml | 4 ++-- .github/workflows/sdk.yml | 2 +- .github/workflows/test.yml | 28 +++++++++++++-------------- 8 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index dca2024760..ce411c19e4 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -167,7 +167,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index a17225513a..f440276397 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -44,7 +44,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './cli/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 125ae28123..3f32478c0c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3.30.8 + uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3.30.8 + uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3.30.8 + uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 24542c8585..2a28b57569 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -69,7 +69,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './docs/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 68267011ce..90810c2cfc 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -32,7 +32,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 97656eaf0c..4aa78ee13a 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -62,13 +62,13 @@ jobs: ref: main - name: Install uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 96e5495cdd..12bdbc55bf 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -31,7 +31,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './open-api/typescript-sdk/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 88db0257ed..8c7eae6532 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -121,7 +121,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -168,7 +168,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -210,7 +210,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -254,7 +254,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -292,7 +292,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -340,7 +340,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -386,7 +386,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -425,7 +425,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -480,7 +480,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -562,7 +562,7 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # with: @@ -608,7 +608,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './.github/.nvmrc' cache: 'pnpm' @@ -659,7 +659,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -721,7 +721,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' From c4ff2ea6d5e0f1b1d982cd0fd63be36b1a6f307a Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 24 Oct 2025 17:07:05 +0200 Subject: [PATCH 015/105] fix: actually use tf output (#23221) --- .github/workflows/docs-deploy.yml | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index f6be54fa52..a74a2ec613 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -185,15 +185,11 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} working-directory: 'deployment/modules/cloudflare/docs' - run: 'mise run tf output -- -json' - - - name: Output Cleaning - id: clean - env: - TG_OUTPUT: ${{ steps.docs-output.outputs.tg_action_output }} run: | - CLEANED=$(echo "$TG_OUTPUT" | sed 's|%0A|\n|g ; s|%3C|<|g' | jq -c .) - echo "output=$CLEANED" >> $GITHUB_OUTPUT + mise run tf output -- -json | jq -r ' + "projectName=\(.pages_project_name.value)", + "subdomain=\(.immich_app_branch_subdomain.value)" + ' >> $GITHUB_OUTPUT - name: Publish to Cloudflare Pages # TODO: Action is deprecated @@ -201,7 +197,7 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - projectName: ${{ fromJson(steps.clean.outputs.output).pages_project_name.value }} + projectName: ${{ steps.docs-output.outputs.projectName }} workingDirectory: 'docs' directory: 'build' branch: ${{ steps.parameters.outputs.name }} @@ -224,6 +220,6 @@ jobs: token: ${{ steps.token.outputs.token }} number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }} body: | - 📖 Documentation deployed to [${{ fromJson(steps.clean.outputs.output).immich_app_branch_subdomain.value }}](https://${{ fromJson(steps.clean.outputs.output).immich_app_branch_subdomain.value }}) + 📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }}) emojis: 'rocket' body-include: '' From d9cddeb0f1805bc8d95a50bd2eca1c4dfbb2d845 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 24 Oct 2025 14:00:51 -0400 Subject: [PATCH 016/105] chore: use reverse proxy during local preview (#23184) --- web/vite.config.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/web/vite.config.ts b/web/vite.config.ts index b44d1c0078..6a2f34cf55 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,7 +4,7 @@ import tailwindcss from '@tailwindcss/vite'; import { svelteTesting } from '@testing-library/svelte/vite'; import path from 'node:path'; import { visualizer } from 'rollup-plugin-visualizer'; -import { defineConfig, type UserConfig } from 'vite'; +import { defineConfig, type ProxyOptions, type UserConfig } from 'vite'; const upstream = { target: process.env.IMMICH_SERVER_URL || 'http://immich-server:2283/', @@ -14,6 +14,12 @@ const upstream = { ws: true, }; +const proxy: Record = { + '/api': upstream, + '/.well-known/immich': upstream, + '/custom.css': upstream, +}; + export default defineConfig({ build: { target: 'es2022', @@ -28,13 +34,12 @@ export default defineConfig({ }, server: { // connect to a remote backend during web-only development - proxy: { - '/api': upstream, - '/.well-known/immich': upstream, - '/custom.css': upstream, - }, + proxy, allowedHosts: true, }, + preview: { + proxy, + }, plugins: [ enhancedImages(), tailwindcss(), From 78fb815cdba71243f95fb3824ef1b933693e9076 Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Fri, 24 Oct 2025 20:41:34 +0200 Subject: [PATCH 017/105] feat(web): add search filter for camera lens model. (#21792) --- i18n/en.json | 1 + mobile/openapi/lib/api/search_api.dart | 13 +++++-- .../lib/model/search_suggestion_type.dart | 3 ++ open-api/immich-openapi-specs.json | 11 +++++- open-api/typescript-sdk/src/fetch-client.ts | 7 +++- server/src/dtos/search.dto.ts | 5 +++ server/src/queries/search.repository.sql | 12 ++++++ server/src/repositories/search.repository.ts | 32 ++++++++++++--- server/src/services/search.service.spec.ts | 20 ++++++++++ server/src/services/search.service.ts | 3 ++ .../search-bar/search-camera-section.svelte | 39 +++++++++++++++++-- web/src/lib/modals/SearchFilterModal.svelte | 2 + 12 files changed, 133 insertions(+), 15 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 02e573de69..8e8913263c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1710,6 +1710,7 @@ "search_by_description_example": "Hiking day in Sapa", "search_by_filename": "Search by file name or extension", "search_by_filename_example": "i.e. IMG_1234.JPG or PNG", + "search_camera_lens_model": "Search lens model...", "search_camera_make": "Search camera make...", "search_camera_model": "Search camera model...", "search_city": "Search city...", diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 4d9e1172b8..788fc333fa 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -123,12 +123,14 @@ class SearchApi { /// * [bool] includeNull: /// This property was added in v111.0.0 /// + /// * [String] lensModel: + /// /// * [String] make: /// /// * [String] model: /// /// * [String] state: - Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { + Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? lensModel, String? make, String? model, String? state, }) async { // ignore: prefer_const_declarations final apiPath = r'/search/suggestions'; @@ -145,6 +147,9 @@ class SearchApi { if (includeNull != null) { queryParams.addAll(_queryParams('', 'includeNull', includeNull)); } + if (lensModel != null) { + queryParams.addAll(_queryParams('', 'lensModel', lensModel)); + } if (make != null) { queryParams.addAll(_queryParams('', 'make', make)); } @@ -181,13 +186,15 @@ class SearchApi { /// * [bool] includeNull: /// This property was added in v111.0.0 /// + /// * [String] lensModel: + /// /// * [String] make: /// /// * [String] model: /// /// * [String] state: - Future?> getSearchSuggestions(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { - final response = await getSearchSuggestionsWithHttpInfo(type, country: country, includeNull: includeNull, make: make, model: model, state: state, ); + Future?> getSearchSuggestions(SearchSuggestionType type, { String? country, bool? includeNull, String? lensModel, String? make, String? model, String? state, }) async { + final response = await getSearchSuggestionsWithHttpInfo(type, country: country, includeNull: includeNull, lensModel: lensModel, make: make, model: model, state: state, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/search_suggestion_type.dart b/mobile/openapi/lib/model/search_suggestion_type.dart index 3f905e029d..b18fe687c4 100644 --- a/mobile/openapi/lib/model/search_suggestion_type.dart +++ b/mobile/openapi/lib/model/search_suggestion_type.dart @@ -28,6 +28,7 @@ class SearchSuggestionType { static const city = SearchSuggestionType._(r'city'); static const cameraMake = SearchSuggestionType._(r'camera-make'); static const cameraModel = SearchSuggestionType._(r'camera-model'); + static const cameraLensModel = SearchSuggestionType._(r'camera-lens-model'); /// List of all possible values in this [enum][SearchSuggestionType]. static const values = [ @@ -36,6 +37,7 @@ class SearchSuggestionType { city, cameraMake, cameraModel, + cameraLensModel, ]; static SearchSuggestionType? fromJson(dynamic value) => SearchSuggestionTypeTypeTransformer().decode(value); @@ -79,6 +81,7 @@ class SearchSuggestionTypeTypeTransformer { case r'city': return SearchSuggestionType.city; case r'camera-make': return SearchSuggestionType.cameraMake; case r'camera-model': return SearchSuggestionType.cameraModel; + case r'camera-lens-model': return SearchSuggestionType.cameraLensModel; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3b258d505f..17aa121718 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6458,6 +6458,14 @@ "type": "boolean" } }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "make", "required": false, @@ -13941,7 +13949,8 @@ "state", "city", "camera-make", - "camera-model" + "camera-model", + "camera-lens-model" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index cdd0047701..1ef73f22a6 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3566,9 +3566,10 @@ export function searchAssetStatistics({ statisticsSearchDto }: { /** * This endpoint requires the `asset.read` permission. */ -export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: { +export function getSearchSuggestions({ country, includeNull, lensModel, make, model, state, $type }: { country?: string; includeNull?: boolean; + lensModel?: string; make?: string; model?: string; state?: string; @@ -3580,6 +3581,7 @@ export function getSearchSuggestions({ country, includeNull, make, model, state, }>(`/search/suggestions${QS.query(QS.explode({ country, includeNull, + lensModel, make, model, state, @@ -4919,7 +4921,8 @@ export enum SearchSuggestionType { State = "state", City = "city", CameraMake = "camera-make", - CameraModel = "camera-model" + CameraModel = "camera-model", + CameraLensModel = "camera-lens-model" } export enum SharedLinkType { Album = "ALBUM", diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 5f8b018afe..2dcf97a574 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -249,6 +249,7 @@ export enum SearchSuggestionType { CITY = 'city', CAMERA_MAKE = 'camera-make', CAMERA_MODEL = 'camera-model', + CAMERA_LENS_MODEL = 'camera-lens-model', } export class SearchSuggestionRequestDto { @@ -271,6 +272,10 @@ export class SearchSuggestionRequestDto { @Optional() model?: string; + @IsString() + @Optional() + lensModel?: string; + @ValidateBoolean({ optional: true }) @PropertyLifecycle({ addedAt: 'v111.0.0' }) includeNull?: boolean; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index e0aaedfdf3..ef5fbe09be 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -290,3 +290,15 @@ where and "visibility" = $2 and "deletedAt" is null and "model" is not null + +-- SearchRepository.getCameraLensModels +select distinct + on ("lensModel") "lensModel" +from + "asset_exif" + inner join "asset" on "asset"."id" = "asset_exif"."assetId" +where + "ownerId" = any ($1::uuid[]) + and "visibility" = $2 + and "deletedAt" is null + and "lensModel" is not null diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 88de2fb06f..650c112591 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -160,10 +160,17 @@ export interface GetCitiesOptions extends GetStatesOptions { export interface GetCameraModelsOptions { make?: string; + lensModel?: string; } export interface GetCameraMakesOptions { model?: string; + lensModel?: string; +} + +export interface GetCameraLensModelsOptions { + make?: string; + model?: string; } @Injectable() @@ -457,25 +464,40 @@ export class SearchRepository { return res.map((row) => row.city!); } - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraMakes(userIds: string[], { model }: GetCameraMakesOptions): Promise { + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) + async getCameraMakes(userIds: string[], { model, lensModel }: GetCameraMakesOptions): Promise { const res = await this.getExifField('make', userIds) .$if(!!model, (qb) => qb.where('model', '=', model!)) + .$if(!!lensModel, (qb) => qb.where('lensModel', '=', lensModel!)) .execute(); return res.map((row) => row.make!); } - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraModels(userIds: string[], { make }: GetCameraModelsOptions): Promise { + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) + async getCameraModels(userIds: string[], { make, lensModel }: GetCameraModelsOptions): Promise { const res = await this.getExifField('model', userIds) .$if(!!make, (qb) => qb.where('make', '=', make!)) + .$if(!!lensModel, (qb) => qb.where('lensModel', '=', lensModel!)) .execute(); return res.map((row) => row.model!); } - private getExifField(field: K, userIds: string[]) { + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getCameraLensModels(userIds: string[], { make, model }: GetCameraLensModelsOptions): Promise { + const res = await this.getExifField('lensModel', userIds) + .$if(!!make, (qb) => qb.where('make', '=', make!)) + .$if(!!model, (qb) => qb.where('model', '=', model!)) + .execute(); + + return res.map((row) => row.lensModel!); + } + + private getExifField( + field: K, + userIds: string[], + ) { return this.db .selectFrom('asset_exif') .select(field) diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index b6e09add19..0dec02f18f 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -179,6 +179,26 @@ describe(SearchService.name, () => { ).resolves.toEqual(['Fujifilm X100VI', null]); expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); + + it('should return search suggestions for camera lens model', async () => { + mocks.search.getCameraLensModels.mockResolvedValue(['10-24mm']); + mocks.partner.getAll.mockResolvedValue([]); + + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_LENS_MODEL }), + ).resolves.toEqual(['10-24mm']); + expect(mocks.search.getCameraLensModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for camera lens model (including null)', async () => { + mocks.search.getCameraLensModels.mockResolvedValue(['10-24mm']); + mocks.partner.getAll.mockResolvedValue([]); + + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_LENS_MODEL }), + ).resolves.toEqual(['10-24mm', null]); + expect(mocks.search.getCameraLensModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); }); describe('searchSmart', () => { diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index fea1670e27..9a6f8321a9 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -177,6 +177,9 @@ export class SearchService extends BaseService { case SearchSuggestionType.CAMERA_MODEL: { return this.searchRepository.getCameraModels(userIds, dto); } + case SearchSuggestionType.CAMERA_LENS_MODEL: { + return this.searchRepository.getCameraLensModels(userIds, dto); + } default: { return Promise.resolve([]); } diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index aa5b0ee7e4..1d451eacc4 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -2,12 +2,11 @@ export interface SearchCameraFilter { make?: string; model?: string; + lensModel?: string; }
@@ -81,5 +102,15 @@ selectedOption={asSelectedOption(modelFilter)} />
+ +
+ (filters.lensModel = option?.value)} + options={asComboboxOptions(lensModels)} + placeholder={$t('search_camera_lens_model')} + selectedOption={asSelectedOption(lensModelFilter)} + /> +
diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte index b9841c311e..2315d3de32 100644 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ b/web/src/lib/modals/SearchFilterModal.svelte @@ -90,6 +90,7 @@ camera: { make: withNullAsUndefined(searchQuery.make), model: withNullAsUndefined(searchQuery.model), + lensModel: withNullAsUndefined(searchQuery.lensModel), }, date: { takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined, @@ -147,6 +148,7 @@ city: filter.location.city, make: filter.camera.make, model: filter.camera.model, + lensModel: filter.camera.lensModel, takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined, takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined, visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined, From c73e3dacea281aef6763f3b8b2f9d860d83cdf35 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:59:30 -0400 Subject: [PATCH 018/105] feat(mobile): high precision seeking (#22346) * millisecond precision video playback * wrap in unawaited * update commit --- .../common/native_video_viewer.page.dart | 58 ++++++++++++------- .../asset_viewer/video_viewer.widget.dart | 54 ++++++++++------- .../video_player_controls_provider.dart | 10 ++-- .../video_player_value_provider.dart | 4 +- .../widgets/asset_viewer/video_position.dart | 2 +- mobile/pubspec.lock | 4 +- mobile/pubspec.yaml | 2 +- 7 files changed, 81 insertions(+), 53 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 7f39d07ec0..5f4eaeaaad 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -26,6 +26,7 @@ import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() class NativeVideoViewerPage extends HookConsumerWidget { + static final log = Logger('NativeVideoViewer'); final Asset asset; final bool showControls; final int playbackDelayFactor; @@ -59,8 +60,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { // Used to show the placeholder during hero animations for remote videos to avoid a stutter final isVisible = useState(Platform.isIOS && asset.isLocal); - final log = Logger('NativeVideoViewerPage'); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isVideoReady = useState(false); @@ -142,7 +141,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { interval: const Duration(milliseconds: 100), maxWaitTime: const Duration(milliseconds: 200), ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { + ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { final playerController = controller.value; if (playerController == null) { return; @@ -153,28 +152,14 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - final oldSeek = (oldControls?.position ?? 0) ~/ 1; - final newSeek = newControls.position ~/ 1; + final oldSeek = oldControls?.position.inMilliseconds; + final newSeek = newControls.position.inMilliseconds; if (oldSeek != newSeek || newControls.restarted) { seekDebouncer.run(() => playerController.seekTo(newSeek)); } if (oldControls?.pause != newControls.pause || newControls.restarted) { - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - try { - if (newControls.pause) { - await playerController.pause(); - } else { - await playerController.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } + unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); } }); @@ -234,7 +219,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - ref.read(videoPlaybackValueProvider.notifier).position = Duration(seconds: playbackInfo.position); + ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); // Check if the video is buffering if (playbackInfo.status == PlaybackStatus.playing) { @@ -391,4 +376,35 @@ class NativeVideoViewerPage extends HookConsumerWidget { ], ); } + + Future _onPauseChange( + BuildContext context, + NativeVideoPlayerController controller, + Debouncer seekDebouncer, + bool isPaused, + ) async { + if (!context.mounted) { + return; + } + + // Make sure the last seek is complete before pausing or playing + // Otherwise, `onPlaybackPositionChanged` can receive outdated events + if (seekDebouncer.isActive) { + await seekDebouncer.drain(); + } + + if (!context.mounted) { + return; + } + + try { + if (isPaused) { + await controller.pause(); + } else { + await controller.play(); + } + } catch (error) { + log.severe('Error pausing or playing video: $error'); + } + } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 3af31950ad..916201aa70 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -46,6 +46,7 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) { } class NativeVideoViewer extends HookConsumerWidget { + static final log = Logger('NativeVideoViewer'); final BaseAsset asset; final bool showControls; final int playbackDelayFactor; @@ -79,8 +80,6 @@ class NativeVideoViewer extends HookConsumerWidget { // Used to show the placeholder during hero animations for remote videos to avoid a stutter final isVisible = useState(Platform.isIOS && asset.hasLocal); - final log = Logger('NativeVideoViewerPage'); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); Future createSource() async { @@ -169,7 +168,7 @@ class NativeVideoViewer extends HookConsumerWidget { interval: const Duration(milliseconds: 100), maxWaitTime: const Duration(milliseconds: 200), ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { + ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { final playerController = controller.value; if (playerController == null) { return; @@ -180,28 +179,14 @@ class NativeVideoViewer extends HookConsumerWidget { return; } - final oldSeek = (oldControls?.position ?? 0) ~/ 1; - final newSeek = newControls.position ~/ 1; + final oldSeek = oldControls?.position.inMilliseconds; + final newSeek = newControls.position.inMilliseconds; if (oldSeek != newSeek || newControls.restarted) { seekDebouncer.run(() => playerController.seekTo(newSeek)); } if (oldControls?.pause != newControls.pause || newControls.restarted) { - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - try { - if (newControls.pause) { - await playerController.pause(); - } else { - await playerController.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } + unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); } }); @@ -263,7 +248,7 @@ class NativeVideoViewer extends HookConsumerWidget { return; } - ref.read(videoPlaybackValueProvider.notifier).position = Duration(seconds: playbackInfo.position); + ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); // Check if the video is buffering if (playbackInfo.status == PlaybackStatus.playing) { @@ -422,4 +407,31 @@ class NativeVideoViewer extends HookConsumerWidget { ], ); } + + Future _onPauseChange( + BuildContext context, + NativeVideoPlayerController controller, + Debouncer seekDebouncer, + bool isPaused, + ) async { + if (!context.mounted) { + return; + } + + // Make sure the last seek is complete before pausing or playing + // Otherwise, `onPlaybackPositionChanged` can receive outdated events + if (seekDebouncer.isActive) { + await seekDebouncer.drain(); + } + + try { + if (isPaused) { + await controller.pause(); + } else { + await controller.play(); + } + } catch (error) { + log.severe('Error pausing or playing video: $error'); + } + } } diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index 3cfc2e2f6f..44740268db 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider class VideoPlaybackControls { const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false}); - final double position; + final Duration position; final bool pause; final bool restarted; } @@ -13,7 +13,7 @@ final videoPlayerControlsProvider = StateNotifierProvider { VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); @@ -30,10 +30,10 @@ class VideoPlayerControls extends StateNotifier { state = videoPlayerControlsDefault; } - double get position => state.position; + Duration get position => state.position; bool get paused => state.pause; - set position(double value) { + set position(Duration value) { if (state.position == value) { return; } @@ -62,7 +62,7 @@ class VideoPlayerControls extends StateNotifier { } void restart() { - state = const VideoPlaybackControls(position: 0, pause: false, restarted: true); + state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true); ref.read(videoPlaybackValueProvider.notifier).value = ref .read(videoPlaybackValueProvider.notifier) .value diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index c478ddd6f5..31b0f4656e 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -33,8 +33,8 @@ class VideoPlaybackValue { }; return VideoPlaybackValue( - position: Duration(seconds: playbackInfo.position), - duration: Duration(seconds: videoInfo.duration), + position: Duration(milliseconds: playbackInfo.position), + duration: Duration(milliseconds: videoInfo.duration), state: status, volume: playbackInfo.volume, ); diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index c12bb5e682..9d9e2821ad 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -61,7 +61,7 @@ class VideoPosition extends HookConsumerWidget { return; } - ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration.inSeconds.toDouble(); + ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration; // This immediately updates the slider position without waiting for the video to update ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 44bb2ae65e..8dc7076b48 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1233,8 +1233,8 @@ packages: dependency: "direct main" description: path: "." - ref: "893894b" - resolved-ref: "893894b98b832be8a995a8d5d4c2289d0ad2d246" + ref: d921ae2 + resolved-ref: d921ae210e294d2821954009ec2cc8aeae918725 url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a6d20a2cb3..2883b38e94 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -57,7 +57,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: '893894b' + ref: 'd921ae2' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: From f721a62776a0b2394c6bf82a69c19b153f5ab1be Mon Sep 17 00:00:00 2001 From: andre-antunesdesa <80642274+andre-antunesdesa@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:03:51 -0400 Subject: [PATCH 019/105] feat(web): load original videos (#20041) * added user preference for always loading original video added ability to toggle between transcoded/original in the video viewer add fix to static check error * address PR comments * Update asset-viewer-nav-bar.svelte Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --------- Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- i18n/en.json | 3 +++ .../asset-viewer/asset-viewer-nav-bar.svelte | 14 ++++++++++++++ .../components/asset-viewer/asset-viewer.svelte | 12 +++++++++++- .../asset-viewer/video-native-viewer.svelte | 11 +++++++++-- .../asset-viewer/video-wrapper-viewer.svelte | 5 ++++- .../user-settings-page/app-settings.svelte | 9 ++++++++- web/src/lib/stores/preferences.store.ts | 2 ++ 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 8e8913263c..dcfa0dc17f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1540,6 +1540,9 @@ "play_memories": "Play memories", "play_motion_photo": "Play Motion Photo", "play_or_pause_video": "Play or pause video", + "play_original_video": "Play original video", + "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", "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index d61af04db6..4a792d7945 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -56,6 +56,7 @@ mdiMagnifyPlusOutline, mdiPresentationPlay, mdiUpload, + mdiVideoOutline, } from '@mdi/js'; import type { Snippet } from 'svelte'; import { t } from 'svelte-i18n'; @@ -78,6 +79,8 @@ // export let showEditorHandler: () => void; onClose: () => void; motionPhoto?: Snippet; + playOriginalVideo: boolean; + setPlayOriginalVideo: (value: boolean) => void; } let { @@ -97,6 +100,8 @@ onShowDetail, onClose, motionPhoto, + playOriginalVideo = false, + setPlayOriginalVideo, }: Props = $props(); const sharedLink = getSharedLink(); @@ -245,6 +250,15 @@ {#if !asset.isTrashed} {/if} + + {#if asset.type === AssetTypeEnum.Video} + setPlayOriginalVideo(!playOriginalVideo)} + text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')} + /> + {/if} +
void 0); + let playOriginalVideo = $state($alwaysLoadOriginalVideo); + + const setPlayOriginalVideo = (value: boolean) => { + playOriginalVideo = value; + }; const refreshStack = async () => { if (authManager.isSharedLink) { @@ -410,6 +415,8 @@ onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onShowDetail={toggleDetailPanel} onClose={closeViewer} + {playOriginalVideo} + {setPlayOriginalVideo} > {#snippet motionPhoto()} navigateAsset()} onVideoStarted={handleVideoStarted} + {playOriginalVideo} /> {/if} {/key} @@ -480,6 +488,7 @@ onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} onVideoEnded={() => (shouldPlayMotionPhoto = false)} + {playOriginalVideo} /> {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath .toLowerCase() @@ -510,6 +519,7 @@ onClose={closeViewer} onVideoEnded={() => navigateAsset()} onVideoStarted={handleVideoStarted} + {playOriginalVideo} /> {/if} {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading} diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index fd3bc1c44f..2ccfb59243 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -10,7 +10,7 @@ videoViewerMuted, videoViewerVolume, } from '$lib/stores/preferences.store'; - import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { AssetMediaSize } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; import { onDestroy, onMount } from 'svelte'; @@ -21,6 +21,7 @@ assetId: string; loopVideo: boolean; cacheKey: string | null; + playOriginalVideo: boolean; onPreviousAsset?: () => void; onNextAsset?: () => void; onVideoEnded?: () => void; @@ -32,6 +33,7 @@ assetId, loopVideo, cacheKey, + playOriginalVideo, onPreviousAsset = () => {}, onNextAsset = () => {}, onVideoEnded = () => {}, @@ -48,7 +50,12 @@ onMount(() => { // Show video after mount to ensure fading in. showVideo = true; - assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey }); + }); + + $effect(() => { + assetFileUrl = playOriginalVideo + ? getAssetOriginalUrl({ id: assetId, cacheKey }) + : getAssetPlaybackUrl({ id: assetId, cacheKey }); if (videoPlayer) { videoPlayer.load(); } diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index a5a94d85d4..748886d901 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -1,13 +1,14 @@ -{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} +{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
- {#await import('../components/shared-components/map/map.svelte')} + {#await import('$lib/components/shared-components/map/map.svelte')} {#await delay(timeToLoadTheMap) then}
diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 1b606c550c..dc3d6a63dd 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,16 +1,16 @@ diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte index 1a18ed7a1f..902d6d79b1 100644 --- a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte @@ -4,7 +4,7 @@ interface Props { query: string | undefined; - queryType?: 'smart' | 'metadata' | 'description'; + queryType?: 'smart' | 'metadata' | 'description' | 'ocr'; } let { query = $bindable(), queryType = $bindable('smart') }: Props = $props(); @@ -28,6 +28,7 @@ bind:group={queryType} value="description" /> +
@@ -63,4 +64,15 @@ bind:value={query} aria-labelledby="description-label" /> +{:else if queryType === 'ocr'} + + {/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 8d2b706ead..2075696b1a 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -138,9 +138,10 @@ export enum QueryType { SMART = 'smart', METADATA = 'metadata', DESCRIPTION = 'description', + OCR = 'ocr', } -export const validQueryTypes = new Set([QueryType.SMART, QueryType.METADATA, QueryType.DESCRIPTION]); +export const validQueryTypes = new Set([QueryType.SMART, QueryType.METADATA, QueryType.DESCRIPTION, QueryType.OCR]); export const locales = [ { code: 'af-ZA', name: 'Afrikaans (South Africa)' }, diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte index 2315d3de32..82011b6678 100644 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ b/web/src/lib/modals/SearchFilterModal.svelte @@ -6,7 +6,8 @@ export type SearchFilter = { query: string; - queryType: 'smart' | 'metadata' | 'description'; + ocr?: string; + queryType: 'smart' | 'metadata' | 'description' | 'ocr'; personIds: SvelteSet; tagIds: SvelteSet | null; location: SearchLocationFilter; @@ -74,6 +75,7 @@ let filter: SearchFilter = $state({ query, + ocr: searchQuery.ocr, queryType: defaultQueryType(), personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), tagIds: @@ -113,6 +115,7 @@ const resetForm = () => { filter = { query: '', + ocr: undefined, queryType: defaultQueryType(), // retain from localStorage or default personIds: new SvelteSet(), tagIds: new SvelteSet(), @@ -141,6 +144,7 @@ let payload: SmartSearchDto | MetadataSearchDto = { query: filter.queryType === 'smart' ? query : undefined, + ocr: filter.queryType === 'ocr' ? query : undefined, originalFileName: filter.queryType === 'metadata' ? query : undefined, description: filter.queryType === 'description' ? query : undefined, country: filter.location.country, diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index ce2d8c2842..46a71ea19e 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -26,6 +26,7 @@ export const featureFlags = writable({ configFile: false, trash: true, email: false, + ocr: true, }); export type ServerConfig = ServerConfigDto & { loaded: boolean }; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 9604ecbbc9..79117f7063 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -162,6 +162,7 @@ export const getJobName = derived(t, ($t) => { [JobName.Library]: $t('external_libraries'), [JobName.Notifications]: $t('notifications'), [JobName.BackupDatabase]: $t('admin.backup_database'), + [JobName.Ocr]: $t('admin.machine_learning_ocr'), }; return names[jobName]; diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7140c2fdd8..52d76ed793 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -205,6 +205,7 @@ originalFileName: $t('file_name'), description: $t('description'), queryAssetId: $t('query_asset_id'), + ocr: $t('ocr'), }; return keyMap[key] || key; } From b0d427f8f9267302af303f39bc97f4573a31799e Mon Sep 17 00:00:00 2001 From: Zac Warham <48937711+ZacWarham@users.noreply.github.com> Date: Mon, 27 Oct 2025 07:21:37 -0700 Subject: [PATCH 027/105] chore: show leading zero week in storage template (#23275) * Use date which shows week with a zero * Update sample date in SupportedDatetimePanel * Update web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte --------- Co-authored-by: Alex --- .../components/admin-settings/SupportedDatetimePanel.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte b/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte index e96db841a3..82588d3be6 100644 --- a/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte +++ b/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte @@ -11,7 +11,7 @@ let { options }: Props = $props(); const getLuxonExample = (format: string) => { - return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format); + return DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }).toFormat(format); }; @@ -23,7 +23,7 @@

{$t('admin.storage_template_date_time_description')}

-

{$t('admin.storage_template_date_time_sample', { values: { date: '2022-09-04T20:03:05.250' } })}

+

{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}

From 3194538817476f74699ca76d865225e4f50e285c Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:52:51 +0530 Subject: [PATCH 028/105] fix: handle null bucketId or name in android local sync (#23224) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../app/alextran/immich/sync/MessagesImplBase.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 0ea86bb10d..ca2781f7b4 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -101,9 +101,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { while (c.moveToNext()) { val id = c.getLong(idColumn).toString() + val name = c.getStringOrNull(nameColumn) + val bucketId = c.getStringOrNull(bucketIdColumn) + val path = c.getStringOrNull(dataColumn) - val path = c.getString(dataColumn) - if (path.isNullOrBlank() || !File(path).exists()) { + // Skip assets with invalid metadata + if ( + name.isNullOrBlank() || bucketId.isNullOrBlank() || + path.isNullOrBlank() || !File(path).exists() + ) { yield(AssetResult.InvalidAsset(id)) continue } @@ -113,7 +119,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2 else -> 0 } - val name = c.getString(nameColumn) // Date taken is milliseconds since epoch, Date added is seconds since epoch val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) ?: c.getLong(dateAddedColumn) @@ -124,7 +129,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { // Duration is milliseconds val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0 else c.getLong(durationColumn) / 1000 - val bucketId = c.getString(bucketIdColumn) val orientation = c.getInt(orientationColumn) val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 From 664a8fa49905edf8ad6250eb3458d5b8d40e0ed3 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:02:24 +0530 Subject: [PATCH 029/105] fix: fetch original name before upload (#21877) * fix: fetch origin name before upload * fix: Show correct photo name in buttom sheet and backup details page (#22978) * add tests --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: FawenYo <40032648+FawenYo@users.noreply.github.com> --- .../drift_backup_asset_detail.page.dart | 104 ++++++----- .../asset_viewer/bottom_sheet.widget.dart | 56 ++++-- mobile/lib/services/upload.service.dart | 23 ++- .../test/infrastructure/repository.mock.dart | 8 + mobile/test/services/upload.service_test.dart | 170 ++++++++++++++++++ 5 files changed, 295 insertions(+), 66 deletions(-) create mode 100644 mobile/test/services/upload.service_test.dart diff --git a/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart b/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart index 3cc675c4ad..f3fdccc329 100644 --- a/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart +++ b/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; @RoutePage() @@ -31,55 +32,66 @@ class DriftBackupAssetDetailPage extends ConsumerWidget { itemBuilder: (context, index) { final asset = candidates[index]; final albumsAsyncValue = ref.watch(driftCandidateBackupAlbumInfoProvider(asset.id)); - return LargeLeadingTile( - title: Text( - asset.name, - style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500, fontSize: 16), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - asset.createdAt.toString(), - style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary), + final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + return FutureBuilder( + future: assetMediaRepository.getOriginalFilename(asset.id), + builder: (context, snapshot) { + final displayName = snapshot.data ?? asset.name; + return LargeLeadingTile( + title: Text( + displayName, + style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500, fontSize: 16), ), - Text( - asset.checksum ?? "N/A", - style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary), - overflow: TextOverflow.ellipsis, - ), - albumsAsyncValue.when( - data: (albums) { - if (albums.isEmpty) { - return const SizedBox.shrink(); - } - return Text( - albums.map((a) => a.name).join(', '), - style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asset.createdAt.toString(), + style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary), + ), + Text( + asset.checksum ?? "N/A", + style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary), overflow: TextOverflow.ellipsis, - ); - }, - error: (error, stackTrace) => Text( - 'error_saving_image'.tr(args: [error.toString()]), - style: TextStyle(color: context.colorScheme.error), - ), - loading: () => const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()), + ), + albumsAsyncValue.when( + data: (albums) { + if (albums.isEmpty) { + return const SizedBox.shrink(); + } + return Text( + albums.map((a) => a.name).join(', '), + style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), + overflow: TextOverflow.ellipsis, + ); + }, + error: (error, stackTrace) => Text( + 'error_saving_image'.tr(args: [error.toString()]), + style: TextStyle(color: context.colorScheme.error), + ), + loading: () => + const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()), + ), + ], ), - ], - ), - leading: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: SizedBox( - width: 64, - height: 64, - child: Thumbnail.fromAsset(asset: asset, size: const Size(64, 64), fit: BoxFit.cover), - ), - ), - trailing: const Padding(padding: EdgeInsets.only(right: 24, left: 8), child: Icon(Icons.image_search)), - onTap: () async { - await context.maybePop(); - await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); - EventStream.shared.emit(ScrollToDateEvent(asset.createdAt)); + leading: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: SizedBox( + width: 64, + height: 64, + child: Thumbnail.fromAsset(asset: asset, size: const Size(64, 64), fit: BoxFit.cover), + ), + ), + trailing: const Padding( + padding: EdgeInsets.only(right: 24, left: 8), + child: Icon(Icons.image_search), + ), + onTap: () async { + await context.maybePop(); + await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); + EventStream.shared.emit(ScrollToDateEvent(asset.createdAt)); + }, + ); }, ); }, diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 9d29b19bff..c4fbd2cfe3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -142,6 +143,47 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final cameraTitle = _getCameraInfoTitle(exifInfo); final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); + // Build file info tile based on asset type + Widget buildFileInfoTile() { + if (asset is LocalAsset) { + final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + return FutureBuilder( + future: assetMediaRepository.getOriginalFilename(asset.id), + builder: (context, snapshot) { + final displayName = snapshot.data ?? asset.name; + return _SheetTile( + title: displayName, + titleStyle: context.textTheme.labelLarge, + leading: Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 24, + color: context.textTheme.labelLarge?.color, + ), + subtitle: _getFileInfo(asset, exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith( + color: context.textTheme.bodyMedium?.color?.withAlpha(155), + ), + ); + }, + ); + } else { + // For remote assets, use the name directly + return _SheetTile( + title: asset.name, + titleStyle: context.textTheme.labelLarge, + leading: Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 24, + color: context.textTheme.labelLarge?.color, + ), + subtitle: _getFileInfo(asset, exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith( + color: context.textTheme.bodyMedium?.color?.withAlpha(155), + ), + ); + } + } + return SliverList.list( children: [ // Asset Date and Time @@ -163,19 +205,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { ), ), // File info - _SheetTile( - title: asset.name, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith( - color: context.textTheme.bodyMedium?.color?.withAlpha(155), - ), - ), + buildFileInfoTile(), // Camera info if (cameraTitle != null) _SheetTile( diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index e8e98562f7..d46268a9d7 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:cancellation_token_http/http.dart'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -31,6 +33,7 @@ final uploadServiceProvider = Provider((ref) { ref.watch(storageRepositoryProvider), ref.watch(localAssetRepository), ref.watch(appSettingsServiceProvider), + ref.watch(assetMediaRepositoryProvider), ); ref.onDispose(service.dispose); @@ -44,6 +47,7 @@ class UploadService { this._storageRepository, this._localAssetRepository, this._appSettingsService, + this._assetMediaRepository, ) { _uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onTaskProgress = _onTaskProgressCallback; @@ -54,6 +58,7 @@ class UploadService { final StorageRepository _storageRepository; final DriftLocalAssetRepository _localAssetRepository; final AppSettingsService _appSettingsService; + final AssetMediaRepository _assetMediaRepository; final Logger _logger = Logger('UploadService'); final StreamController _taskStatusController = StreamController.broadcast(); @@ -98,7 +103,7 @@ class UploadService { await _storageRepository.clearCache(); List tasks = []; for (final asset in localAssets) { - final task = await _getUploadTask( + final task = await getUploadTask( asset, group: kManualUploadGroup, priority: 1, // High priority after upload motion photo part @@ -136,7 +141,7 @@ class UploadService { final batch = candidates.skip(i).take(batchSize).toList(); List tasks = []; for (final asset in batch) { - final task = await _getUploadTask(asset); + final task = await getUploadTask(asset); if (task != null) { tasks.add(task); } @@ -248,7 +253,7 @@ class UploadService { return; } - final uploadTask = await _getLivePhotoUploadTask(localAsset, response['id'] as String); + final uploadTask = await getLivePhotoUploadTask(localAsset, response['id'] as String); if (uploadTask == null) { return; @@ -296,7 +301,8 @@ class UploadService { ); } - Future _getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async { + @visibleForTesting + Future getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async { final entity = await _storageRepository.getAssetEntityForAsset(asset); if (entity == null) { return null; @@ -324,7 +330,8 @@ class UploadService { return null; } - final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; + final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; + final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; String metadata = UploadTaskMetadata( localAssetId: asset.id, @@ -348,7 +355,8 @@ class UploadService { ); } - Future _getLivePhotoUploadTask(LocalAsset asset, String livePhotoVideoId) async { + @visibleForTesting + Future getLivePhotoUploadTask(LocalAsset asset, String livePhotoVideoId) async { final entity = await _storageRepository.getAssetEntityForAsset(asset); if (entity == null) { return null; @@ -362,12 +370,13 @@ class UploadService { final fields = {'livePhotoVideoId': livePhotoVideoId}; final requiresWiFi = _shouldRequireWiFi(asset); + final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; return buildUploadTask( file, createdAt: asset.createdAt, modifiedAt: asset.updatedAt, - originalFileName: asset.name, + originalFileName: originalFileName, deviceAssetId: asset.id, fields: fields, group: kBackupLivePhotoGroup, diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 1b66451dda..44e756e88e 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; @@ -10,6 +11,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreRepository extends Mock implements IsarStoreRepository {} @@ -30,8 +32,14 @@ class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumReposito class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} +class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} + class MockStorageRepository extends Mock implements StorageRepository {} +class MockDriftBackupRepository extends Mock implements DriftBackupRepository {} + +class MockUploadRepository extends Mock implements UploadRepository {} + // API Repos class MockUserApiRepository extends Mock implements UserApiRepository {} diff --git a/mobile/test/services/upload.service_test.dart b/mobile/test/services/upload.service_test.dart new file mode 100644 index 0000000000..b18ad7b7d4 --- /dev/null +++ b/mobile/test/services/upload.service_test.dart @@ -0,0 +1,170 @@ +import 'dart:io'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/upload.service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:photo_manager/photo_manager.dart'; + +import '../domain/service.mock.dart'; +import '../fixtures/asset.stub.dart'; +import '../infrastructure/repository.mock.dart'; +import '../repository.mocks.dart'; + +class MockAssetEntity extends Mock implements AssetEntity {} + +void main() { + late UploadService sut; + late MockUploadRepository mockUploadRepository; + late MockDriftBackupRepository mockBackupRepository; + late MockStorageRepository mockStorageRepository; + late MockDriftLocalAssetRepository mockLocalAssetRepository; + late MockAppSettingsService mockAppSettingsService; + late MockAssetMediaRepository mockAssetMediaRepository; + late Drift db; + + setUpAll(() async { + registerFallbackValue(AppSettingsEnum.useCellularForUploadPhotos); + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (MethodCall methodCall) async => 'test', + ); + db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await StoreService.init(storeRepository: DriftStoreRepository(db)); + + await Store.put(StoreKey.serverEndpoint, 'http://test-server.com'); + await Store.put(StoreKey.deviceId, 'test-device-id'); + }); + + setUp(() { + mockUploadRepository = MockUploadRepository(); + mockBackupRepository = MockDriftBackupRepository(); + mockStorageRepository = MockStorageRepository(); + mockLocalAssetRepository = MockDriftLocalAssetRepository(); + mockAppSettingsService = MockAppSettingsService(); + mockAssetMediaRepository = MockAssetMediaRepository(); + + when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false); + when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false); + + sut = UploadService( + mockUploadRepository, + mockBackupRepository, + mockStorageRepository, + mockLocalAssetRepository, + mockAppSettingsService, + mockAssetMediaRepository, + ); + + mockUploadRepository.onUploadStatus = (_) {}; + mockUploadRepository.onTaskProgress = (_) {}; + }); + + tearDown(() { + sut.dispose(); + }); + + group('getUploadTask', () { + test('should call getOriginalFilename from AssetMediaRepository for regular photo', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/file.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg'); + + final task = await sut.getUploadTask(asset); + + expect(task, isNotNull); + expect(task!.fields['filename'], equals('OriginalPhoto.jpg')); + verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); + }); + + test('should call getOriginalFilename when original filename is null', () async { + final asset = LocalAssetStub.image2; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/file.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); + + final task = await sut.getUploadTask(asset); + + expect(task, isNotNull); + expect(task!.fields['filename'], equals(asset.name)); + verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); + }); + + test('should call getOriginalFilename for live photo', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/file.mov'); + + when(() => mockEntity.isLivePhoto).thenReturn(true); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile); + when( + () => mockAssetMediaRepository.getOriginalFilename(asset.id), + ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + // For live photos, extension should be changed to match the video file + expect(task!.fields['filename'], equals('OriginalLivePhoto.mov')); + verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); + }); + }); + + group('getLivePhotoUploadTask', () { + test('should call getOriginalFilename for live photo upload task', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/livephoto.heic'); + + when(() => mockEntity.isLivePhoto).thenReturn(true); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when( + () => mockAssetMediaRepository.getOriginalFilename(asset.id), + ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); + + final task = await sut.getLivePhotoUploadTask(asset, 'video-id-123'); + + expect(task, isNotNull); + expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC')); + expect(task.fields['livePhotoVideoId'], equals('video-id-123')); + verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); + }); + + test('should call getOriginalFilename when original filename is null', () async { + final asset = LocalAssetStub.image2; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/fallback.heic'); + + when(() => mockEntity.isLivePhoto).thenReturn(true); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); + + final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456'); + expect(task, isNotNull); + // Should fall back to asset.name when original filename is null + expect(task!.fields['filename'], equals(asset.name)); + verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); + }); + }); +} From ac0d64640197d221c028e10c83bee012c23fdea6 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:02:52 +0530 Subject: [PATCH 030/105] fix: mobile unawaited_futures lint (#21661) * chore: add unawaited_futures lint as warning # Conflicts: # mobile/analysis_options.yaml * remove unused dcm lints They will be added back later on a case by case basis * fix warning # Conflicts: # mobile/lib/presentation/pages/drift_remote_album.page.dart * auto gen file * review changes * conflict resolution --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/analysis_options.yaml | 159 +----------------- .../services/background_worker.service.dart | 4 +- .../domain/services/local_sync.service.dart | 2 +- .../loaders/remote_image_request.dart | 8 +- .../repositories/local_album.repository.dart | 14 +- mobile/lib/main.dart | 2 +- .../lib/pages/album/album_options.page.dart | 4 +- .../album_shared_user_selection.page.dart | 10 +- .../pages/backup/backup_controller.page.dart | 2 +- .../lib/pages/backup/drift_backup.page.dart | 2 +- .../backup/drift_upload_detail.page.dart | 4 +- mobile/lib/pages/common/activities.page.dart | 2 +- .../lib/pages/common/create_album.page.dart | 6 +- .../lib/pages/common/gallery_viewer.page.dart | 2 +- .../common/native_video_viewer.page.dart | 14 +- .../lib/pages/common/splash_screen.page.dart | 76 +++++---- mobile/lib/pages/editing/crop.page.dart | 13 +- mobile/lib/pages/editing/filter.page.dart | 11 +- .../pages/library/locked/pin_auth.page.dart | 8 +- .../shared_link/shared_link_edit.page.dart | 2 +- mobile/lib/pages/photos/photos.page.dart | 10 +- mobile/lib/pages/search/map/map.page.dart | 9 +- .../search/map/map_location_picker.page.dart | 6 +- mobile/lib/pages/search/search.page.dart | 4 +- .../pages/drift_album_options.page.dart | 4 +- .../pages/drift_create_album.page.dart | 4 +- .../pages/drift_remote_album.page.dart | 106 ++++++------ .../pages/editing/drift_crop.page.dart | 4 +- .../pages/editing/drift_edit.page.dart | 2 +- .../pages/editing/drift_filter.page.dart | 2 +- .../pages/search/drift_search.page.dart | 4 +- .../advanced_info_action_button.widget.dart | 4 +- .../share_action_button.widget.dart | 2 +- .../widgets/album/album_selector.widget.dart | 4 +- .../asset_viewer/asset_viewer.page.dart | 4 +- .../asset_viewer/video_viewer.widget.dart | 14 +- .../widgets/images/image_provider.dart | 6 +- .../widgets/images/local_image_provider.dart | 4 +- .../widgets/images/remote_image_provider.dart | 4 +- .../presentation/widgets/map/map.widget.dart | 16 +- .../presentation/widgets/map/map_utils.dart | 2 +- .../widgets/timeline/fixed/segment.model.dart | 15 +- .../providers/app_life_cycle.provider.dart | 4 +- mobile/lib/providers/asset.provider.dart | 2 +- .../asset_viewer/download.provider.dart | 42 ++--- .../share_intent_upload.provider.dart | 2 +- .../lib/providers/backup/backup.provider.dart | 8 +- .../backup/backup_verification.provider.dart | 8 +- .../backup_verification.provider.g.dart | 2 +- .../backup/manual_upload.provider.dart | 5 +- .../image/immich_local_image_provider.dart | 3 +- .../infrastructure/action.provider.dart | 6 +- .../lib/providers/shared_link.provider.dart | 4 +- .../repositories/asset_media.repository.dart | 25 +-- .../lib/routing/app_navigation_observer.dart | 4 +- mobile/lib/routing/auth_guard.dart | 7 +- .../lib/routing/backup_permission_guard.dart | 4 +- mobile/lib/routing/gallery_guard.dart | 16 +- mobile/lib/routing/locked_guard.dart | 9 +- mobile/lib/services/action.service.dart | 4 +- mobile/lib/services/album.service.dart | 2 +- mobile/lib/services/api.service.dart | 6 +- mobile/lib/services/auth.service.dart | 2 +- mobile/lib/services/background.service.dart | 38 +++-- .../services/backup_verification.service.dart | 2 +- mobile/lib/services/map.service.dart | 4 +- mobile/lib/services/share.service.dart | 12 +- mobile/lib/services/sync.service.dart | 2 +- mobile/lib/services/upload.service.dart | 4 +- mobile/lib/utils/map_utils.dart | 6 +- mobile/lib/utils/selection_handlers.dart | 4 +- .../widgets/album/album_viewer_appbar.dart | 6 +- .../widgets/asset_grid/multiselect_grid.dart | 8 +- .../asset_viewer/bottom_gallery_bar.dart | 39 +++-- .../lib/widgets/asset_viewer/cast_dialog.dart | 4 +- .../asset_viewer/detail_panel/exif_map.dart | 5 +- .../common/app_bar_dialog/app_bar_dialog.dart | 44 ++--- .../app_bar_dialog/app_bar_profile_info.dart | 6 +- .../lib/widgets/forms/login/login_form.dart | 17 +- mobile/lib/widgets/map/map_bottom_sheet.dart | 10 +- .../settings/beta_timeline_list_tile.dart | 4 +- .../local_network_preference.dart | 6 +- .../domain/services/store_service_test.dart | 4 +- .../repositories/store_repository_test.dart | 40 +++-- .../activity/activities_page_test.dart | 6 +- .../activity/activity_text_field_test.dart | 4 +- .../modules/activity/activity_tile_test.dart | 6 +- .../test/modules/utils/async_mutex_test.dart | 8 +- 88 files changed, 491 insertions(+), 538 deletions(-) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index c04e1dafdc..275a38a970 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -17,7 +17,7 @@ linter: # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. + # https://dart.dev/tools/linter-rules # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code @@ -28,6 +28,7 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + unawaited_futures: true use_build_context_synchronously: false require_trailing_commas: true unrelated_type_equality_checks: true @@ -46,6 +47,8 @@ analyzer: # TODO: Re-enable after upgrading custom_lint # plugins: # - custom_lint + errors: + unawaited_futures: warning custom_lint: debug: true @@ -152,160 +155,6 @@ dart_code_metrics: # - avoid-passing-async-when-sync-expected # - avoid-throw-in-catch-block - avoid-unused-parameters - # - avoid-unnecessary-type-assertions - # - avoid-unnecessary-type-casts - # - avoid-unrelated-type-assertions - # - avoid-unrelated-type-casts - # - no-empty-block - # - no-equal-then-else - # - prefer-correct-test-file-name - prefer-const-border-radius - # - prefer-match-file-name - # - prefer-return-await - # - avoid-self-assignment - # - avoid-self-compare - # - avoid-shadowing - # - prefer-iterable-of - # - no-equal-switch-case - # - no-equal-conditions - # - avoid-equal-expressions - # - avoid-missed-calls - # - avoid-unnecessary-negations - # - avoid-unused-generics - # - function-always-returns-null - # - avoid-throw-objects-without-tostring - # - avoid-unsafe-collection-methods - # - prefer-wildcard-pattern - # - no-equal-switch-expression-cases - # - avoid-future-tostring - # - avoid-unassigned-late-fields - # - avoid-nested-futures - # - avoid-generics-shadowing - # - prefer-parentheses-with-if-null - # - no-equal-nested-conditions - # - avoid-shadowed-extension-methods - # - avoid-unnecessary-conditionals - # - avoid-double-slash-imports - # - avoid-map-keys-contains - # - prefer-correct-json-casts - # - avoid-duplicate-mixins - # - avoid-nullable-interpolation - # - avoid-unused-instances - # - prefer-correct-for-loop-increment - # - prefer-public-exception-classes - # - avoid-uncaught-future-errors - # - always-remove-listener - # - avoid-unnecessary-setstate - # - check-for-equals-in-render-object-setters - # - consistent-update-render-object - # - use-setstate-synchronously - # - avoid-incomplete-copy-with - # - proper-super-calls - # - dispose-fields - # - avoid-empty-setstate - # - avoid-state-constructors - # - avoid-recursive-widget-calls - # - avoid-missing-image-alt - # - avoid-passing-self-as-argument - # - avoid-unnecessary-if - # - avoid-unconditional-break - # - avoid-referencing-discarded-variables - # - avoid-unnecessary-local-late - # - avoid-wildcard-cases-with-enums - # - match-getter-setter-field-names - # - avoid-accessing-collections-by-constant-index - # - prefer-unique-test-names - # - avoid-duplicate-cascades - # - prefer-specific-cases-first - # - avoid-duplicate-switch-case-conditions - # - prefer-explicit-function-type - # - avoid-misused-test-matchers - # - avoid-duplicate-test-assertions - # - prefer-switch-with-enums - # - prefer-any-or-every - # - avoid-duplicate-map-keys - # - avoid-nullable-tostring - # - avoid-undisposed-instances - # - avoid-duplicate-initializers - # - avoid-unassigned-stream-subscriptions - # - avoid-empty-test-groups - # - avoid-not-encodable-in-to-json - # - avoid-contradictory-expressions - # - avoid-excessive-expressions - # - prefer-private-extension-type-field - # - avoid-renaming-representation-getters - # - avoid-empty-spread - # - avoid-unnecessary-gesture-detector - # - avoid-missing-completer-stack-trace - # - avoid-casting-to-extension-type - # - prefer-overriding-parent-equality - # - avoid-missing-controller - # - avoid-unknown-pragma - # - avoid-conditions-with-boolean-literals - # - avoid-multi-assignment - # - avoid-collection-equality-checks - # - avoid-only-rethrow - # - avoid-incorrect-image-opacity - # - avoid-misused-set-literals - # - dispose-class-fields - # - avoid-suspicious-super-overrides - # - avoid-assignments-as-conditions - # - avoid-unused-assignment - # - avoid-unnecessary-overrides - # - avoid-implicitly-nullable-extension-types - # Enable with the next release - # - avoid-late-final-reassignment - # - avoid-duplicate-constant-values - # - function-always-returns-same-value - # - avoid-flexible-outside-flex - # - avoid-unnecessary-patterns - # - use-closest-build-context - # - avoid-commented-out-code - # - avoid-recursive-tostring - # - avoid-enum-values-by-index - # - avoid-constant-assert-conditions - # - avoid-inconsistent-digit-separators - # - pass-existing-future-to-future-builder - # - pass-existing-stream-to-stream-builder - - # Code simplification - # - avoid-redundant-async - # - avoid-redundant-else - # - avoid-unnecessary-nullable-return-type - # - avoid-redundant-pragma-inline - # - avoid-nested-records - # - avoid-redundant-positional-field-name - # - avoid-explicit-pattern-field-name - # - prefer-simpler-patterns-null-check - # - avoid-unnecessary-return - # - avoid-duplicate-patterns - # - avoid-keywords-in-wildcard-pattern - # - avoid-unnecessary-futures - # - avoid-unnecessary-reassignment - # - avoid-unnecessary-call - # - avoid-unnecessary-stateful-widgets - # - prefer-dedicated-media-query-methods - # - avoid-unnecessary-overrides-in-state - # - move-variable-closer-to-its-usage - # - avoid-nullable-parameters-with-default-values - # - prefer-null-aware-spread - # - avoid-inferrable-type-arguments - # - avoid-unnecessary-super - # - avoid-unnecessary-collections - # - avoid-unnecessary-extends - # - avoid-unnecessary-enum-arguments - # - prefer-contains - # Enable with the next release - # - prefer-simpler-boolean-expressions - # - prefer-spacing - # - avoid-unnecessary-continue - # - avoid-unnecessary-compare-to - - # Style - # - prefer-trailing-comma - # - unnecessary-trailing-comma - prefer-declaring-const-constructor - # - prefer-single-widget-per-file - prefer-switch-expression - # - prefer-prefixed-global-constants - # - prefer-correct-callback-field-name diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index e6ac3eaebd..5c228ba67c 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -114,10 +114,10 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { configureFileDownloaderNotifications(); // Notify the host that the background worker service has been initialized and is ready to use - _backgroundHostApi.onInitialized(); + unawaited(_backgroundHostApi.onInitialized()); } catch (error, stack) { _logger.severe("Failed to initialize background worker", error, stack); - _backgroundHostApi.close(); + unawaited(_backgroundHostApi.close()); } } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index ca356c80d8..94a8a19e73 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -249,7 +249,7 @@ class LocalSyncService { if (assetsToUpsert.isEmpty && assetsToDelete.isEmpty) { _log.fine("No asset changes detected in album ${deviceAlbum.name}. Updating metadata."); - _localAlbumRepository.upsert(updatedDeviceAlbum); + await _localAlbumRepository.upsert(updatedDeviceAlbum); return true; } diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 78f6b9479b..03dcd6454a 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -68,7 +68,7 @@ class RemoteImageRequest extends ImageRequest { final cacheManager = this.cacheManager; final streamController = StreamController>(sync: true); final Stream> stream; - cacheManager?.putStreamedFile(url, streamController.stream); + unawaited(cacheManager?.putStreamedFile(url, streamController.stream)); stream = response.map((chunk) { if (_isCancelled) { throw StateError('Cancelled request'); @@ -81,11 +81,11 @@ class RemoteImageRequest extends ImageRequest { try { final Uint8List bytes = await _downloadBytes(stream, response.contentLength); - streamController.close(); + unawaited(streamController.close()); return await ImmutableBuffer.fromUint8List(bytes); } catch (e) { streamController.addError(e); - streamController.close(); + unawaited(streamController.close()); if (_isCancelled) { return null; } @@ -143,7 +143,7 @@ class RemoteImageRequest extends ImageRequest { return await _decodeBuffer(buffer, decode, scale); } catch (e) { log.severe('Failed to decode cached image', e); - _evictFile(url); + unawaited(_evictFile(url)); return null; } } diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index e4bff24879..63259bc62b 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -361,15 +361,13 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { return _db.managers.localAlbumEntity.count(); } - Future unlinkRemoteAlbum(String id) async { - return _db.localAlbumEntity.update() - ..where((row) => row.id.equals(id)) - ..write(const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null))); + Future unlinkRemoteAlbum(String id) async { + final query = _db.localAlbumEntity.update()..where((row) => row.id.equals(id)); + await query.write(const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null))); } - Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async { - return _db.localAlbumEntity.update() - ..where((row) => row.id.equals(localAlbumId)) - ..write(LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(remoteAlbumId))); + Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async { + final query = _db.localAlbumEntity.update()..where((row) => row.id.equals(localAlbumId)); + await query.write(LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(remoteAlbumId))); } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index b1d87b36ab..c3804d97f6 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -159,7 +159,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve WidgetsBinding.instance.addObserver(this); // Draw the app from edge to edge - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); // Sets the navigation bar color SystemUiOverlayStyle overlayStyle = const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent); diff --git a/mobile/lib/pages/album/album_options.page.dart b/mobile/lib/pages/album/album_options.page.dart index 20d4dbd325..b0f682ffed 100644 --- a/mobile/lib/pages/album/album_options.page.dart +++ b/mobile/lib/pages/album/album_options.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -51,7 +53,7 @@ class AlbumOptionsPage extends HookConsumerWidget { final isSuccess = await ref.read(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { - context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); + unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()]))); } else { showErrorMessage(); } diff --git a/mobile/lib/pages/album/album_shared_user_selection.page.dart b/mobile/lib/pages/album/album_shared_user_selection.page.dart index 562f02a2ab..ec084b1859 100644 --- a/mobile/lib/pages/album/album_shared_user_selection.page.dart +++ b/mobile/lib/pages/album/album_shared_user_selection.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -29,8 +31,8 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget { if (newAlbum != null) { ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); - context.maybePop(true); - context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); + unawaited(context.maybePop(true)); + unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()]))); } ScaffoldMessenger( @@ -109,8 +111,8 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget { centerTitle: false, leading: IconButton( icon: const Icon(Icons.close_rounded), - onPressed: () async { - context.maybePop(); + onPressed: () { + unawaited(context.maybePop()); }, ), actions: [ diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index 4f55d00ea0..1e008be1bb 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -155,7 +155,7 @@ class BackupControllerPage extends HookConsumerWidget { // waited until returning from selection await ref.read(backupProvider.notifier).backupAlbumSelectionDone(); // waited until backup albums are stored in DB - ref.read(albumProvider.notifier).refreshDeviceAlbums(); + await ref.read(albumProvider.notifier).refreshDeviceAlbums(); }, child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(), ), diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 2e7c3e946c..47052ea436 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -270,7 +270,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { if (currentUser == null) { return; } - ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); + unawaited(ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id)); }, child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(), ), diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 80956b708f..1b8aa57eaa 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -170,8 +170,8 @@ class DriftUploadDetailPage extends ConsumerWidget { ); } - Future _showFileDetailDialog(BuildContext context, DriftUploadStatus item) async { - showDialog( + Future _showFileDetailDialog(BuildContext context, DriftUploadStatus item) { + return showDialog( context: context, builder: (context) => FileDetailDialog(uploadStatus: item), ); diff --git a/mobile/lib/pages/common/activities.page.dart b/mobile/lib/pages/common/activities.page.dart index 1a1955af40..9d1123dbca 100644 --- a/mobile/lib/pages/common/activities.page.dart +++ b/mobile/lib/pages/common/activities.page.dart @@ -33,7 +33,7 @@ class ActivitiesPage extends HookConsumerWidget { Future onAddComment(String comment) async { await activityNotifier.addComment(comment); // Scroll to the end of the list to show the newly added activity - listViewScrollController.animateTo( + await listViewScrollController.animateTo( listViewScrollController.position.maxScrollExtent + 200, duration: const Duration(milliseconds: 600), curve: Curves.fastOutSlowIn, diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 5a0d4154f8..0a28dfeb5a 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -170,11 +172,11 @@ class CreateAlbumPage extends HookConsumerWidget { .createAlbum(ref.read(albumTitleProvider), selectedAssets.value); if (newAlbum != null) { - ref.read(albumProvider.notifier).refreshRemoteAlbums(); + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); selectedAssets.value = {}; ref.read(albumTitleProvider.notifier).clearAlbumTitle(); ref.read(albumViewerProvider.notifier).disableEditAlbum(); - context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id)); + unawaited(context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id))); } } diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 3c279dfcd2..9a7e78ddb8 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -95,7 +95,7 @@ class GalleryViewerPage extends HookConsumerWidget { } catch (e) { // swallow error silently log.severe('Error precaching next image: $e'); - context.maybePop(); + await context.maybePop(); } } diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 5f4eaeaaad..9cd9f6bd5e 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -267,11 +267,13 @@ class NativeVideoViewerPage extends HookConsumerWidget { nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackEnded.addListener(onPlaybackEnded); - nc.loadVideoSource(source).catchError((error) { - log.severe('Error loading video source: $error'); - }); + unawaited( + nc.loadVideoSource(source).catchError((error) { + log.severe('Error loading video source: $error'); + }), + ); final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); - nc.setLoop(loopVideo); + unawaited(nc.setLoop(loopVideo)); controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); @@ -342,12 +344,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { useOnAppLifecycleStateChange((_, state) async { if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { - controller.value?.play(); + await controller.value?.play(); } else if (state == AppLifecycleState.paused) { final videoPlaying = await controller.value?.isPlaying(); if (videoPlaying ?? true) { shouldPlayOnForeground.value = true; - controller.value?.pause(); + await controller.value?.pause(); } else { shouldPlayOnForeground.value = false; } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 29b3dcd3be..c1d621f474 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -55,48 +55,50 @@ class SplashScreenPageState extends ConsumerState { final backgroundManager = ref.read(backgroundSyncProvider); final backupProvider = ref.read(driftBackupProvider.notifier); - ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( - (_) async { - try { - wsProvider.connect(); - infoProvider.getServerInfo(); + unawaited( + ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( + (_) async { + try { + wsProvider.connect(); + unawaited(infoProvider.getServerInfo()); - if (Store.isBetaTimelineEnabled) { - bool syncSuccess = false; - await Future.wait([ - backgroundManager.syncLocal(), - backgroundManager.syncRemote().then((success) => syncSuccess = success), - ]); - - if (syncSuccess) { + if (Store.isBetaTimelineEnabled) { + bool syncSuccess = false; await Future.wait([ - backgroundManager.hashAssets().then((_) { - _resumeBackup(backupProvider); - }), - _resumeBackup(backupProvider), + backgroundManager.syncLocal(), + backgroundManager.syncRemote().then((success) => syncSuccess = success), ]); - } else { - await backgroundManager.hashAssets(); - } - if (Store.get(StoreKey.syncAlbums, false)) { - await backgroundManager.syncLinkedAlbum(); + if (syncSuccess) { + await Future.wait([ + backgroundManager.hashAssets().then((_) { + _resumeBackup(backupProvider); + }), + _resumeBackup(backupProvider), + ]); + } else { + await backgroundManager.hashAssets(); + } + + if (Store.get(StoreKey.syncAlbums, false)) { + await backgroundManager.syncLinkedAlbum(); + } } + } catch (e) { + log.severe('Failed establishing connection to the server: $e'); } - } catch (e) { - log.severe('Failed establishing connection to the server: $e'); - } - }, - onError: (exception) => { - log.severe('Failed to update auth info with access token: $accessToken'), - ref.read(authProvider.notifier).logout(), - context.replaceRoute(const LoginRoute()), - }, + }, + onError: (exception) => { + log.severe('Failed to update auth info with access token: $accessToken'), + ref.read(authProvider.notifier).logout(), + context.replaceRoute(const LoginRoute()), + }, + ), ); } else { log.severe('Missing crucial offline login info - Logging out completely'); - ref.read(authProvider.notifier).logout(); - context.replaceRoute(const LoginRoute()); + unawaited(ref.read(authProvider.notifier).logout()); + unawaited(context.replaceRoute(const LoginRoute())); return; } @@ -106,11 +108,11 @@ class SplashScreenPageState extends ConsumerState { final needBetaMigration = Store.get(StoreKey.needBetaMigration, false); if (needBetaMigration) { await Store.put(StoreKey.needBetaMigration, false); - context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]); + unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)])); return; } - context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()); + unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute())); } if (Store.isBetaTimelineEnabled) { @@ -120,7 +122,7 @@ class SplashScreenPageState extends ConsumerState { final hasPermission = await ref.read(galleryPermissionNotifier.notifier).hasPermission; if (hasPermission) { // Resume backup (if enable) then navigate - ref.watch(backupProvider.notifier).resumeBackup(); + await ref.watch(backupProvider.notifier).resumeBackup(); } } @@ -130,7 +132,7 @@ class SplashScreenPageState extends ConsumerState { if (isEnableBackup) { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { - notifier.handleBackupResume(currentUser.id); + unawaited(notifier.handleBackupResume(currentUser.id)); } } } diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index 35fd615800..8cd13fed64 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -1,13 +1,16 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; import 'package:crop_image/crop_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; + import 'edit.page.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:auto_route/auto_route.dart'; /// A widget for cropping an image. /// This widget uses [HookWidget] to manage its lifecycle and state. It allows @@ -35,7 +38,7 @@ class CropImagePage extends HookWidget { icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24), onPressed: () async { final croppedImage = await cropController.croppedImage(); - context.pushRoute(EditImageRoute(asset: asset, image: croppedImage, isEdited: true)); + unawaited(context.pushRoute(EditImageRoute(asset: asset, image: croppedImage, isEdited: true))); }, ), ], diff --git a/mobile/lib/pages/editing/filter.page.dart b/mobile/lib/pages/editing/filter.page.dart index 6d41b4c5b8..f8b144bb96 100644 --- a/mobile/lib/pages/editing/filter.page.dart +++ b/mobile/lib/pages/editing/filter.page.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'dart:ui' as ui; + +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'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/constants/filters.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:auto_route/auto_route.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; /// A widget for filtering an image. @@ -74,7 +75,7 @@ class FilterImagePage extends HookWidget { icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24), onPressed: () async { final filteredImage = await applyFilterAndConvert(colorFilter.value); - context.pushRoute(EditImageRoute(asset: asset, image: filteredImage, isEdited: true)); + unawaited(context.pushRoute(EditImageRoute(asset: asset, image: filteredImage, isEdited: true))); }, ), ], diff --git a/mobile/lib/pages/library/locked/pin_auth.page.dart b/mobile/lib/pages/library/locked/pin_auth.page.dart index 36befa0016..a39c91871b 100644 --- a/mobile/lib/pages/library/locked/pin_auth.page.dart +++ b/mobile/lib/pages/library/locked/pin_auth.page.dart @@ -1,14 +1,16 @@ +import 'dart:async'; + 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' show useState; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/local_auth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/forms/pin_registration_form.dart'; import 'package:immich_mobile/widgets/forms/pin_verification_form.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; @RoutePage() class PinAuthPage extends HookConsumerWidget { @@ -35,9 +37,9 @@ class PinAuthPage extends HookConsumerWidget { ); if (isBetaTimeline) { - context.replaceRoute(const DriftLockedFolderRoute()); + unawaited(context.replaceRoute(const DriftLockedFolderRoute())); } else { - context.replaceRoute(const LockedRoute()); + unawaited(context.replaceRoute(const LockedRoute())); } } } diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index 8b66bb231f..1d7eaef080 100644 --- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -333,7 +333,7 @@ class SharedLinkEditPage extends HookConsumerWidget { changeExpiry: changeExpiry, ); ref.invalidate(sharedLinksStateProvider); - context.maybePop(); + await context.maybePop(); } return Scaffold( diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 957c32b224..7f57247ec4 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -82,10 +82,12 @@ class PhotosPage extends HookConsumerWidget { final fullRefresh = refreshCount.value > 0; if (fullRefresh) { - Future.wait([ - ref.read(assetProvider.notifier).getAllAsset(clear: true), - ref.read(albumProvider.notifier).refreshRemoteAlbums(), - ]); + unawaited( + Future.wait([ + ref.read(assetProvider.notifier).getAllAsset(clear: true), + ref.read(albumProvider.notifier).refreshRemoteAlbums(), + ]), + ); // refresh was forced: user requested another refresh within 2 seconds refreshCount.value = 0; diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index 34522f0f04..a93b826f03 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:auto_route/auto_route.dart'; @@ -83,7 +84,7 @@ class MapPage extends HookConsumerWidget { isLoading.value = true; markers.value = await ref.read(mapMarkersProvider.future); assetsDebouncer.run(updateAssetsInBounds); - reloadLayers(); + await reloadLayers(); } finally { isLoading.value = false; } @@ -128,7 +129,7 @@ class MapPage extends HookConsumerWidget { ); if (marker != null) { - updateAssetMarkerPosition(marker); + await updateAssetMarkerPosition(marker); } else { // If no asset was previously selected and no new asset is available, close the bottom sheet if (selectedMarker.value == null) { @@ -165,7 +166,7 @@ class MapPage extends HookConsumerWidget { if (asset.isVideo) { ref.read(showControlsProvider.notifier).show = false; } - context.pushRoute(GalleryViewerRoute(initialIndex: 0, heroOffset: 0, renderList: renderList)); + unawaited(context.pushRoute(GalleryViewerRoute(initialIndex: 0, heroOffset: 0, renderList: renderList))); } /// BOTTOM SHEET CALLBACKS @@ -209,7 +210,7 @@ class MapPage extends HookConsumerWidget { } if (mapController.value != null && location != null) { - mapController.value!.animateCamera( + await mapController.value!.animateCamera( CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), mapZoomToAssetLevel), duration: const Duration(milliseconds: 800), ); diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart index 0fe8b769f5..94e6627c98 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -8,9 +8,9 @@ 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/extensions/maplibrecontroller_extensions.dart'; +import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:immich_mobile/utils/map_utils.dart'; @RoutePage() class MapLocationPickerPage extends HookConsumerWidget { @@ -30,7 +30,7 @@ class MapLocationPickerPage extends HookConsumerWidget { Future onMapClick(Point point, LatLng centre) async { selectedLatLng.value = centre; - controller.value?.animateCamera(CameraUpdate.newLatLng(centre)); + await controller.value?.animateCamera(CameraUpdate.newLatLng(centre)); if (marker.value != null) { await controller.value?.updateSymbol(marker.value!, SymbolOptions(geometry: centre)); } @@ -49,7 +49,7 @@ class MapLocationPickerPage extends HookConsumerWidget { var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude); selectedLatLng.value = currentLatLng; - controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng)); + await controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng)); } return MapThemeOverride( diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 7623829b51..902110f6a8 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -266,7 +266,7 @@ class SearchPage extends HookConsumerWidget { filter.value = filter.value.copyWith(date: SearchDateFilter()); dateRangeCurrentFilterWidget.value = null; - search(); + unawaited(search()); return; } @@ -295,7 +295,7 @@ class SearchPage extends HookConsumerWidget { ); } - search(); + unawaited(search()); } // MEDIA PICKER diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 7f49a1ff79..2116e5c5cc 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -47,7 +49,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { void leaveAlbum() async { try { await ref.read(remoteAlbumProvider.notifier).leaveAlbum(album.id, userId: userId); - context.navigateTo(const DriftAlbumsRoute()); + unawaited(context.navigateTo(const DriftAlbumsRoute())); } catch (_) { showErrorMessage(); } diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index c70c4a0bd7..2e263ba1db 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -179,7 +181,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { if (album != null) { ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); - context.replaceRoute(RemoteAlbumRoute(album: album)); + unawaited(context.replaceRoute(RemoteAlbumRoute(album: album))); } } diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 23d82dcb92..2d70978ea5 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -139,7 +141,7 @@ class _RemoteAlbumPageState extends ConsumerState { toastType: ToastType.success, ); - context.pushRoute(const DriftAlbumsRoute()); + unawaited(context.pushRoute(const DriftAlbumsRoute())); } catch (e) { ImmichToast.show( context: context, @@ -161,12 +163,12 @@ class _RemoteAlbumPageState extends ConsumerState { setState(() { _album = _album.copyWith(name: result.name, description: result.description ?? ''); }); - HapticFeedback.mediumImpact(); + unawaited(HapticFeedback.mediumImpact()); } } Future showActivity(BuildContext context) async { - context.pushRoute(const DriftActivitiesRoute()); + unawaited(context.pushRoute(const DriftActivitiesRoute())); } Future showOptionSheet(BuildContext context) async { @@ -175,56 +177,58 @@ class _RemoteAlbumPageState extends ConsumerState { final canAddPhotos = await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor; - showModalBottomSheet( - context: context, - backgroundColor: context.colorScheme.surface, - isScrollControlled: false, - builder: (context) { - return DriftRemoteAlbumOption( - onDeleteAlbum: isOwner - ? () async { - await deleteAlbum(context); - if (context.mounted) { + unawaited( + showModalBottomSheet( + context: context, + backgroundColor: context.colorScheme.surface, + isScrollControlled: false, + builder: (context) { + return DriftRemoteAlbumOption( + onDeleteAlbum: isOwner + ? () async { + await deleteAlbum(context); + if (context.mounted) { + context.pop(); + } + } + : null, + onAddUsers: isOwner + ? () async { + await addUsers(context); context.pop(); } - } - : null, - onAddUsers: isOwner - ? () async { - await addUsers(context); - context.pop(); - } - : null, - onAddPhotos: isOwner || canAddPhotos - ? () async { - await addAssets(context); - context.pop(); - } - : null, - onToggleAlbumOrder: isOwner - ? () async { - await toggleAlbumOrder(); - context.pop(); - } - : null, - onEditAlbum: isOwner - ? () async { - context.pop(); - await showEditTitleAndDescription(context); - } - : null, - onCreateSharedLink: isOwner - ? () async { - context.pop(); - context.pushRoute(SharedLinkEditRoute(albumId: _album.id)); - } - : null, - onShowOptions: () { - context.pop(); - context.pushRoute(const DriftAlbumOptionsRoute()); - }, - ); - }, + : null, + onAddPhotos: isOwner || canAddPhotos + ? () async { + await addAssets(context); + context.pop(); + } + : null, + onToggleAlbumOrder: isOwner + ? () async { + await toggleAlbumOrder(); + context.pop(); + } + : null, + onEditAlbum: isOwner + ? () async { + context.pop(); + await showEditTitleAndDescription(context); + } + : null, + onCreateSharedLink: isOwner + ? () async { + context.pop(); + unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id))); + } + : null, + onShowOptions: () { + context.pop(); + context.pushRoute(const DriftAlbumOptionsRoute()); + }, + ); + }, + ), ); } diff --git a/mobile/lib/presentation/pages/editing/drift_crop.page.dart b/mobile/lib/presentation/pages/editing/drift_crop.page.dart index 5b14292aa2..d8219e3b3c 100644 --- a/mobile/lib/presentation/pages/editing/drift_crop.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_crop.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:crop_image/crop_image.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -34,7 +36,7 @@ class DriftCropImagePage extends HookWidget { icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24), onPressed: () async { final croppedImage = await cropController.croppedImage(); - context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)); + unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true))); }, ), ], diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index e24a1967f2..f9903b6b94 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -70,7 +70,7 @@ class DriftEditImagePage extends ConsumerWidget { Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e); } - ref.read(backgroundSyncProvider).syncLocal(full: true); + unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true)); _exitEditing(context); ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!'); diff --git a/mobile/lib/presentation/pages/editing/drift_filter.page.dart b/mobile/lib/presentation/pages/editing/drift_filter.page.dart index 75c3f81de2..8198a41bbe 100644 --- a/mobile/lib/presentation/pages/editing/drift_filter.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_filter.page.dart @@ -75,7 +75,7 @@ class DriftFilterImagePage extends HookWidget { icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24), onPressed: () async { final filteredImage = await applyFilterAndConvert(colorFilter.value); - context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true)); + unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true))); }, ), ], diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 069beef33e..965e31678e 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -271,7 +271,7 @@ class DriftSearchPage extends HookConsumerWidget { filter.value = filter.value.copyWith(date: SearchDateFilter()); dateRangeCurrentFilterWidget.value = null; - search(); + unawaited(search()); return; } @@ -301,7 +301,7 @@ class DriftSearchPage extends HookConsumerWidget { ); } - search(); + unawaited(search()); } // MEDIA PICKER diff --git a/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart index 170f827fdb..cb2581bc6d 100644 --- a/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -15,7 +17,7 @@ class AdvancedInfoActionButton extends ConsumerWidget { return; } - ref.read(actionProvider.notifier).troubleshoot(source, context); + unawaited(ref.read(actionProvider.notifier).troubleshoot(source, context)); } @override diff --git a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart index 740ac528b0..6bcf099487 100644 --- a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart @@ -39,7 +39,7 @@ class ShareActionButton extends ConsumerWidget { return; } - showDialog( + await showDialog( context: context, builder: (BuildContext buildContext) { ref.read(actionProvider.notifier).shareAssets(source, context).then((ActionResult result) { diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index bffe3d3941..cbac6c8b93 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -121,7 +121,7 @@ class _AlbumSelectorState extends ConsumerState { // we need to re-filter the albums after sorting // so shownAlbums gets updated - filterAlbums(); + unawaited(filterAlbums()); } Future filterAlbums() async { @@ -711,7 +711,7 @@ class AddToAlbumHeader extends ConsumerWidget { ref.read(currentRemoteAlbumProvider.notifier).setAlbum(newAlbum); ref.read(multiSelectProvider.notifier).reset(); - context.pushRoute(RemoteAlbumRoute(album: newAlbum)); + unawaited(context.pushRoute(RemoteAlbumRoute(album: newAlbum))); } return SliverPadding( diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 12f8771982..2e3009d934 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -635,9 +635,9 @@ class _AssetViewerState extends ConsumerState { // Listen for control visibility changes and change system UI mode accordingly ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async { if (showingControls) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); } }); diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 916201aa70..08b5b25343 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -295,11 +295,13 @@ class NativeVideoViewer extends HookConsumerWidget { nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackEnded.addListener(onPlaybackEnded); - nc.loadVideoSource(source).catchError((error) { - log.severe('Error loading video source: $error'); - }); + unawaited( + nc.loadVideoSource(source).catchError((error) { + log.severe('Error loading video source: $error'); + }), + ); final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); - nc.setLoop(!asset.isMotionPhoto && loopVideo); + unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo)); controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); @@ -373,12 +375,12 @@ class NativeVideoViewer extends HookConsumerWidget { useOnAppLifecycleStateChange((_, state) async { if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { - controller.value?.play(); + await controller.value?.play(); } else if (state == AppLifecycleState.paused) { final videoPlaying = await controller.value?.isPlaying(); if (videoPlaying ?? true) { shouldPlayOnForeground.value = true; - controller.value?.pause(); + await controller.value?.pause(); } else { shouldPlayOnForeground.value = false; } diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 810340aeb8..e77803c206 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:async/async.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -51,14 +53,14 @@ mixin CancellableImageProviderMixin on CancellableImageProvide Stream loadRequest(ImageRequest request, ImageDecoderCallback decode) async* { if (isCancelled) { this.request = null; - evict(); + unawaited(evict()); return; } try { final image = await request.load(decode); if (image == null || isCancelled) { - evict(); + unawaited(evict()); return; } yield image; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index f90961ea5a..c5dca57f9c 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -85,7 +85,7 @@ class LocalFullImageProvider extends CancellableImageProvider { } final bounds = await controller.getVisibleRegion(); - _reloadMutex.run(() async { - if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) { - final markers = await ref.read(mapMarkerProvider(bounds).future); - await reloadMarkers(markers); - } - }); + unawaited( + _reloadMutex.run(() async { + if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) { + final markers = await ref.read(mapMarkerProvider(bounds).future); + await reloadMarkers(markers); + } + }), + ); } Future reloadMarkers(Map markers) async { @@ -148,7 +150,7 @@ class _DriftMapState extends ConsumerState { final controller = mapController; if (controller != null && location != null) { - controller.animateCamera( + await controller.animateCamera( CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), MapUtils.mapZoomToAssetLevel), duration: const Duration(milliseconds: 800), ); diff --git a/mobile/lib/presentation/widgets/map/map_utils.dart b/mobile/lib/presentation/widgets/map/map_utils.dart index 1c18fc48d6..80df5995b6 100644 --- a/mobile/lib/presentation/widgets/map/map_utils.dart +++ b/mobile/lib/presentation/widgets/map/map_utils.dart @@ -73,7 +73,7 @@ class MapUtils { try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled && !silent) { - showDialog(context: context, builder: (context) => _LocationServiceDisabledDialog(context)); + unawaited(showDialog(context: context, builder: (context) => _LocationServiceDisabledDialog(context))); return (null, LocationPermission.deniedForever); } diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 8121bb76d3..a0a98c34d7 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; @@ -15,8 +16,8 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart' import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -156,11 +157,13 @@ class _AssetTileWidget extends ConsumerWidget { await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1); ref.read(isPlayingMotionVideoProvider.notifier).playing = false; AssetViewer.setAsset(ref, asset); - ctx.pushRoute( - AssetViewerRoute( - initialIndex: assetIndex, - timelineService: ref.read(timelineServiceProvider), - heroOffset: heroOffset, + unawaited( + ctx.pushRoute( + AssetViewerRoute( + initialIndex: assetIndex, + timelineService: ref.read(timelineServiceProvider), + heroOffset: heroOffset, + ), ), ); } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 3b51874ab5..6d0c0acb0d 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -242,7 +242,7 @@ class AppLifeCycleNotifier extends StateNotifier { } try { - LogService.I.flush(); + await LogService.I.flush(); } catch (_) {} } @@ -255,7 +255,7 @@ class AppLifeCycleNotifier extends StateNotifier { // Flush logs before closing database try { - LogService.I.flush(); + await LogService.I.flush(); } catch (_) {} // Close Isar database safely diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index 75635950ff..d5a4e42b74 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -98,7 +98,7 @@ class AssetNotifier extends StateNotifier { Future onNewAssetUploaded(Asset newAsset) async { // eTag on device is not valid after partially modifying the assets - Store.delete(StoreKey.assetETag); + await Store.delete(StoreKey.assetETag); await _syncService.syncNewAssetToDb(newAsset); } diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 36b935abe7..a461d5766a 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -1,14 +1,16 @@ +import 'dart:async'; + import 'package:background_downloader/background_downloader.dart'; 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/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/download/download_state.model.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/download.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/services/share.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/share_dialog.dart'; @@ -159,24 +161,26 @@ class DownloadStateNotifier extends StateNotifier { } void shareAsset(Asset asset, BuildContext context) async { - showDialog( - context: context, - builder: (BuildContext buildContext) { - _shareService.shareAsset(asset, context).then((bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }); - return const ShareDialog(); - }, - barrierDismissible: false, - useRootNavigator: false, + unawaited( + showDialog( + context: context, + builder: (BuildContext buildContext) { + _shareService.shareAsset(asset, context).then((bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + buildContext.pop(); + }); + return const ShareDialog(); + }, + barrierDismissible: false, + useRootNavigator: false, + ), ); } } diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index 7b2ab5b27a..0f9c32b410 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -104,7 +104,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier upload(File file) async { final task = await _buildUploadTask(hash(file.path).toString(), file); - _uploadService.enqueueTasks([task]); + await _uploadService.enqueueTasks([task]); } Future _buildUploadTask(String id, File file, {Map? fields}) async { diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 03666466ff..9eb01b6109 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -380,7 +380,7 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(backgroundBackup: isEnabled); if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) { - Store.put(StoreKey.backgroundBackup, isEnabled); + await Store.put(StoreKey.backgroundBackup, isEnabled); } if (state.backupProgress != BackUpProgressEnum.inBackground) { @@ -474,7 +474,7 @@ class BackupNotifier extends StateNotifier { ); await notifyBackgroundServiceCanRun(); } else { - openAppSettings(); + await openAppSettings(); } } @@ -533,10 +533,10 @@ class BackupNotifier extends StateNotifier { progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, ); - _updatePersistentAlbumsSelection(); + await _updatePersistentAlbumsSelection(); } - updateDiskInfo(); + await updateDiskInfo(); } void _onUploadProgress(int sent, int total) { diff --git a/mobile/lib/providers/backup/backup_verification.provider.dart b/mobile/lib/providers/backup/backup_verification.provider.dart index da4253576b..50270e87ca 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/providers/backup/backup.provider.dart'; -import 'package:immich_mobile/services/backup_verification.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/services/backup_verification.service.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -44,7 +44,7 @@ class BackupVerification extends _$BackupVerification { } return; } - WakelockPlus.enable(); + unawaited(WakelockPlus.enable()); const limit = 100; final toDelete = await ref.read(backupVerificationServiceProvider).findWronglyBackedUpAssets(limit: limit); @@ -73,7 +73,7 @@ class BackupVerification extends _$BackupVerification { } } } finally { - WakelockPlus.disable(); + unawaited(WakelockPlus.disable()); state = false; } } diff --git a/mobile/lib/providers/backup/backup_verification.provider.g.dart b/mobile/lib/providers/backup/backup_verification.provider.g.dart index 727e06a12c..13f6819fa7 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.g.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.g.dart @@ -7,7 +7,7 @@ part of 'backup_verification.provider.dart'; // ************************************************************************** String _$backupVerificationHash() => - r'b204e43ab575d5fa5b2ee663297f32bcee9074f5'; + r'b4b34909ed1af3f28877ea457d53a4a18b6417f8'; /// See also [BackupVerification]. @ProviderFor(BackupVerification) diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index bfc079bfa3..6ad8730356 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:cancellation_token_http/http.dart'; @@ -26,11 +27,11 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; -import 'package:immich_mobile/utils/debug_print.dart'; final manualUploadProvider = StateNotifierProvider((ref) { return ManualUploadNotifier( @@ -294,7 +295,7 @@ class ManualUploadNotifier extends StateNotifier { ); } } else { - openAppSettings(); + unawaited(openAppSettings()); dPrint(() => "[_startUpload] Do not have permission to the gallery"); } } catch (e) { diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart index 8c46c52906..b9e09eb357 100644 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ b/mobile/lib/providers/image/immich_local_image_provider.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; - import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -77,7 +76,7 @@ class ImmichLocalImageProvider extends ImageProvider { } catch (error, stack) { log.severe('Error loading local image ${asset.fileName}', error, stack); } finally { - chunkEvents.close(); + unawaited(chunkEvents.close()); } } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 7542934426..9467f63483 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; @@ -70,7 +72,7 @@ class ActionNotifier extends Notifier { void _downloadLivePhotoCallback(TaskStatusUpdate update) async { if (update.status == TaskStatus.complete) { final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id; - _downloadService.saveLivePhotos(update.task, livePhotosId); + unawaited(_downloadService.saveLivePhotos(update.task, livePhotosId)); } } @@ -131,7 +133,7 @@ class ActionNotifier extends Notifier { if (assets.length > 1) { return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets'); } - context.pushRoute(AssetTroubleshootRoute(asset: assets.first)); + unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first))); return ActionResult(count: assets.length, success: true); } diff --git a/mobile/lib/providers/shared_link.provider.dart b/mobile/lib/providers/shared_link.provider.dart index f574554bcb..fb44aea203 100644 --- a/mobile/lib/providers/shared_link.provider.dart +++ b/mobile/lib/providers/shared_link.provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/services/shared_link.service.dart'; @@ -16,7 +18,7 @@ class SharedLinksNotifier extends StateNotifier>> { Future deleteLink(String id) async { await _sharedLinkService.deleteSharedLink(id); state = const AsyncLoading(); - fetchLinks(); + unawaited(fetchLinks()); } } diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index f71c919373..e377ff22d6 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; @@ -138,18 +139,20 @@ class AssetMediaRepository { // we dont want to await the share result since the // "preparing" dialog will not disappear until final size = context.sizeData; - Share.shareXFiles( - downloadedXFiles, - sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)), - ).then((result) async { - for (var file in tempFiles) { - try { - await file.delete(); - } catch (e) { - _log.warning("Failed to delete temporary file: ${file.path}", e); + unawaited( + Share.shareXFiles( + downloadedXFiles, + sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)), + ).then((result) async { + for (var file in tempFiles) { + try { + await file.delete(); + } catch (e) { + _log.warning("Failed to delete temporary file: ${file.path}", e); + } } - } - }); + }), + ); return downloadedXFiles.length; } diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index 26ec017b9a..b05a28172d 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,7 +14,7 @@ class AppNavigationObserver extends AutoRouterObserver { @override Future didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async { - Future(() => ref.read(inLockedViewProvider.notifier).state = false); + unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false)); } @override diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index 33eb8e81ad..b0cd9ea9ea 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:auto_route/auto_route.dart'; @@ -26,18 +27,18 @@ class AuthGuard extends AutoRouteGuard { if (res == null || res.authStatus != true) { // If the access token is invalid, take user back to login _log.fine('User token is invalid. Redirecting to login'); - router.replaceAll([const LoginRoute()]); + unawaited(router.replaceAll([const LoginRoute()])); } } on StoreKeyNotFoundException catch (_) { // If there is no access token, take us to the login page _log.warning('No access token in the store.'); - router.replaceAll([const LoginRoute()]); + unawaited(router.replaceAll([const LoginRoute()])); return; } on ApiException catch (e) { // On an unauthorized request, take us to the login page if (e.code == HttpStatus.unauthorized) { _log.warning("Unauthorized access token."); - router.replaceAll([const LoginRoute()]); + unawaited(router.replaceAll([const LoginRoute()])); return; } } catch (e) { diff --git a/mobile/lib/routing/backup_permission_guard.dart b/mobile/lib/routing/backup_permission_guard.dart index 245a4b27af..f52516f2e5 100644 --- a/mobile/lib/routing/backup_permission_guard.dart +++ b/mobile/lib/routing/backup_permission_guard.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -13,7 +15,7 @@ class BackupPermissionGuard extends AutoRouteGuard { if (p) { resolver.next(true); } else { - router.push(const PermissionOnboardingRoute()); + unawaited(router.push(const PermissionOnboardingRoute())); } } } diff --git a/mobile/lib/routing/gallery_guard.dart b/mobile/lib/routing/gallery_guard.dart index eace8257b6..6a4b1bddab 100644 --- a/mobile/lib/routing/gallery_guard.dart +++ b/mobile/lib/routing/gallery_guard.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -13,12 +15,14 @@ class GalleryGuard extends AutoRouteGuard { // Replace instead of pushing duplicate final args = resolver.route.args as GalleryViewerRouteArgs; - router.replace( - GalleryViewerRoute( - renderList: args.renderList, - initialIndex: args.initialIndex, - heroOffset: args.heroOffset, - showStack: args.showStack, + unawaited( + router.replace( + GalleryViewerRoute( + renderList: args.renderList, + initialIndex: args.initialIndex, + heroOffset: args.heroOffset, + showStack: args.showStack, + ), ), ); // Prevent further navigation since we replaced the route diff --git a/mobile/lib/routing/locked_guard.dart b/mobile/lib/routing/locked_guard.dart index 851407ff16..ddb6a7e694 100644 --- a/mobile/lib/routing/locked_guard.dart +++ b/mobile/lib/routing/locked_guard.dart @@ -1,8 +1,9 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/routing/router.dart'; - import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/local_auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; @@ -30,7 +31,7 @@ class LockedGuard extends AutoRouteGuard { /// Check if a pincode has been created but this user. Show the form to create if not exist if (!authStatus.pinCode) { - router.push(PinAuthRoute(createPinCode: true)); + unawaited(router.push(PinAuthRoute(createPinCode: true))); } if (authStatus.isElevated) { @@ -42,7 +43,7 @@ class LockedGuard extends AutoRouteGuard { /// the user has enabled the biometric authentication final securePinCode = await _secureStorageService.read(kSecuredPinCode); if (securePinCode == null) { - router.push(PinAuthRoute()); + unawaited(router.push(PinAuthRoute())); return; } @@ -74,7 +75,7 @@ class LockedGuard extends AutoRouteGuard { } on ApiException { // PIN code has changed, need to re-enter to access await _secureStorageService.delete(kSecuredPinCode); - router.push(PinAuthRoute()); + unawaited(router.push(PinAuthRoute())); } catch (error) { _log.severe("Failed to access locked page", error); resolver.next(false); diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 9c3768080b..59b627ecc3 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -50,7 +52,7 @@ class ActionService { ); Future shareLink(List remoteIds, BuildContext context) async { - context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)); + unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds))); } Future favorite(List remoteIds) async { diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index a9eee0528e..8d77b569e6 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -83,7 +83,7 @@ class AlbumService { if (selectedIds.isEmpty) { final numLocal = await _albumRepository.count(local: true); if (numLocal > 0) { - _syncService.removeAllLocalAlbumsAndAssets(); + await _syncService.removeAllLocalAlbumsAndAssets(); } return false; } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 4033ffb184..698ac3a159 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -6,11 +6,11 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/url_helper.dart'; +import 'package:immich_mobile/utils/user_agent.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -import 'package:immich_mobile/utils/user_agent.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; class ApiService implements Authentication { late ApiClient _apiClient; @@ -86,7 +86,7 @@ class ApiService implements Authentication { setEndpoint(endpoint); // Save in local database for next startup - Store.put(StoreKey.serverEndpoint, endpoint); + await Store.put(StoreKey.serverEndpoint, endpoint); return endpoint; } diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 91c23cac1c..3173f49957 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -58,7 +58,7 @@ class AuthService { Future validateServerUrl(String url) async { final validUrl = await _apiService.resolveAndSetEndpoint(url); await _apiService.setDeviceInfoHeader(); - Store.put(StoreKey.serverUrl, validUrl); + await Store.put(StoreKey.serverUrl, validUrl); return validUrl; } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 33a8e810f1..b69aa53014 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -291,7 +291,7 @@ class BackgroundService { case "backgroundProcessing": case "onAssetsChanged": try { - _clearErrorNotifications(); + unawaited(_clearErrorNotifications()); // iOS should time out after some threshold so it doesn't wait // indefinitely and can run later @@ -342,7 +342,7 @@ class BackgroundService { ); HttpSSLOptions.apply(); - ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); + await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); @@ -385,7 +385,7 @@ class BackgroundService { await ref.read(backupAlbumRepositoryProvider).deleteAll(toDelete); await ref.read(backupAlbumRepositoryProvider).updateAll(toUpsert); } else if (Store.tryGet(StoreKey.backupFailedSince) == null) { - Store.put(StoreKey.backupFailedSince, DateTime.now()); + await Store.put(StoreKey.backupFailedSince, DateTime.now()); return false; } // Android should check for new assets added while performing backup @@ -412,9 +412,11 @@ class BackgroundService { try { toUpload = await backupService.removeAlreadyUploadedAssets(toUpload); } catch (e) { - _showErrorNotification( - title: "backup_background_service_error_title".tr(), - content: "backup_background_service_connection_failed_message".tr(), + unawaited( + _showErrorNotification( + title: "backup_background_service_error_title".tr(), + content: "backup_background_service_connection_failed_message".tr(), + ), ); return false; } @@ -428,13 +430,15 @@ class BackgroundService { } _assetsToUploadCount = toUpload.length; _uploadedAssetsCount = 0; - _updateNotification( - title: "backup_background_service_in_progress_notification".tr(), - content: notifyTotalProgress ? formatAssetBackupProgress(_uploadedAssetsCount, _assetsToUploadCount) : null, - progress: 0, - max: notifyTotalProgress ? _assetsToUploadCount : 0, - indeterminate: !notifyTotalProgress, - onlyIfFG: !notifyTotalProgress, + unawaited( + _updateNotification( + title: "backup_background_service_in_progress_notification".tr(), + content: notifyTotalProgress ? formatAssetBackupProgress(_uploadedAssetsCount, _assetsToUploadCount) : null, + progress: 0, + max: notifyTotalProgress ? _assetsToUploadCount : 0, + indeterminate: !notifyTotalProgress, + onlyIfFG: !notifyTotalProgress, + ), ); _cancellationToken = CancellationToken(); @@ -452,9 +456,11 @@ class BackgroundService { ); if (!ok && !_cancellationToken!.isCancelled) { - _showErrorNotification( - title: "backup_background_service_error_title".tr(), - content: "backup_background_service_backup_failed_message".tr(), + unawaited( + _showErrorNotification( + title: "backup_background_service_error_title".tr(), + content: "backup_background_service_backup_failed_message".tr(), + ), ); } diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 94c4721cca..1e8d426df8 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -120,7 +120,7 @@ class BackupVerificationService { await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); - apiService.setAccessToken(tuple.auth); + await apiService.setAccessToken(tuple.auth); for (int i = 0; i < tuple.deleteCandidates.length; i++) { if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) { result.add(tuple.deleteCandidates[i]); diff --git a/mobile/lib/services/map.service.dart b/mobile/lib/services/map.service.dart index 2d236f77ef..5b50e8a890 100644 --- a/mobile/lib/services/map.service.dart +++ b/mobile/lib/services/map.service.dart @@ -1,9 +1,9 @@ import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/user_agent.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:immich_mobile/utils/user_agent.dart'; class MapService with ErrorLoggerMixin { final ApiService _apiService; @@ -16,7 +16,7 @@ class MapService with ErrorLoggerMixin { Future _setMapUserAgentHeader() async { final userAgent = await getUserAgentString(); - setHttpHeaders({'User-Agent': userAgent}); + await setHttpHeaders({'User-Agent': userAgent}); } Future> getMapMarkers({ diff --git a/mobile/lib/services/share.service.dart b/mobile/lib/services/share.service.dart index 7ba385d71c..06a4a192d4 100644 --- a/mobile/lib/services/share.service.dart +++ b/mobile/lib/services/share.service.dart @@ -1,13 +1,15 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; + import 'api.service.dart'; final shareServiceProvider = Provider((ref) => ShareService(ref.watch(apiServiceProvider))); @@ -58,9 +60,11 @@ class ShareService { } final size = MediaQuery.of(context).size; - Share.shareXFiles( - downloadedXFiles, - sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)), + unawaited( + Share.shareXFiles( + downloadedXFiles, + sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)), + ), ); return true; } catch (error) { diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 1a5cb2a116..f5b55f36eb 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -705,7 +705,7 @@ class SyncService { if (assets.isEmpty) return; if (Platform.isAndroid && _appSettingsService.getSetting(AppSettingsEnum.manageLocalMediaAndroid)) { - _toggleTrashStatusForAssets(assets); + await _toggleTrashStatusForAssets(assets); } try { diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index d46268a9d7..1ce0cf0322 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -214,7 +214,7 @@ class UploadService { void _handleTaskStatusUpdate(TaskStatusUpdate update) async { switch (update.status) { case TaskStatus.complete: - _handleLivePhoto(update); + unawaited(_handleLivePhoto(update)); if (CurrentPlatform.isIOS) { try { @@ -259,7 +259,7 @@ class UploadService { return; } - enqueueTasks([uploadTask]); + await enqueueTasks([uploadTask]); } catch (error, stackTrace) { dPrint(() => "Error handling live photo upload task: $error $stackTrace"); } diff --git a/mobile/lib/utils/map_utils.dart b/mobile/lib/utils/map_utils.dart index 80e20b7c6c..6213b214a9 100644 --- a/mobile/lib/utils/map_utils.dart +++ b/mobile/lib/utils/map_utils.dart @@ -1,8 +1,10 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -68,7 +70,7 @@ class MapUtils { try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled && !silent) { - showDialog(context: context, builder: (context) => _LocationServiceDisabledDialog()); + unawaited(showDialog(context: context, builder: (context) => _LocationServiceDisabledDialog())); return (null, LocationPermission.deniedForever); } diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index d128ef8fac..f0d333e262 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -101,7 +101,7 @@ Future handleEditDateTime(WidgetRef ref, BuildContext context, List return; } - ref.read(assetServiceProvider).changeDateTime(selection.toList(), dateTime); + await ref.read(assetServiceProvider).changeDateTime(selection.toList(), dateTime); } Future handleEditLocation(WidgetRef ref, BuildContext context, List selection) async { @@ -120,7 +120,7 @@ Future handleEditLocation(WidgetRef ref, BuildContext context, List return; } - ref.read(assetServiceProvider).changeLocation(selection.toList(), location); + await ref.read(assetServiceProvider).changeLocation(selection.toList(), location); } Future handleSetAssetsVisibility( diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 420218d7e5..4fd4b31013 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -57,7 +59,7 @@ class AlbumViewerAppbar extends HookConsumerWidget implements PreferredSizeWidge deleteAlbum() async { final bool success = await ref.watch(albumProvider.notifier).deleteAlbum(album); - context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); + unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()]))); if (!success) { ImmichToast.show( @@ -105,7 +107,7 @@ class AlbumViewerAppbar extends HookConsumerWidget implements PreferredSizeWidge bool isSuccess = await ref.watch(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { - context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); + unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()]))); } else { context.pop(); ImmichToast.show( diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index da2957c027..c0d8a6bea2 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -314,10 +314,10 @@ class MultiselectGrid extends HookConsumerWidget { final result = await ref.read(albumServiceProvider).createAlbumWithGeneratedName(assets); if (result != null) { - ref.watch(albumProvider.notifier).refreshRemoteAlbums(); + unawaited(ref.watch(albumProvider.notifier).refreshRemoteAlbums()); selectionEnabledHook.value = false; - context.pushRoute(AlbumViewerRoute(albumId: result.id)); + unawaited(context.pushRoute(AlbumViewerRoute(albumId: result.id))); } } finally { processing.value = false; @@ -346,7 +346,7 @@ class MultiselectGrid extends HookConsumerWidget { ); if (remoteAssets.isNotEmpty) { - handleEditDateTime(ref, context, remoteAssets.toList()); + unawaited(handleEditDateTime(ref, context, remoteAssets.toList())); } } finally { selectionEnabledHook.value = false; @@ -361,7 +361,7 @@ class MultiselectGrid extends HookConsumerWidget { ); if (remoteAssets.isNotEmpty) { - handleEditLocation(ref, context, remoteAssets.toList()); + unawaited(handleEditLocation(ref, context, remoteAssets.toList())); } } finally { selectionEnabledHook.value = false; diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 00f7bc494d..5707e3678f 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:auto_route/auto_route.dart'; @@ -81,7 +82,7 @@ class BottomGalleryBar extends ConsumerWidget { // to not throw the error when the next preCache index is called if (totalAssets.value == 1 || assetIndex.value == totalAssets.value - 1) { // Handle only one asset - context.maybePop(); + await context.maybePop(); } totalAssets.value -= 1; @@ -111,18 +112,20 @@ class BottomGalleryBar extends ConsumerWidget { } // Asset is permanently removed - showDialog( - context: context, - builder: (BuildContext _) { - return DeleteDialog( - onDelete: () async { - final isDeleted = await onDelete(true); - if (isDeleted) { - removeAssetFromStack(); - } - }, - ); - }, + unawaited( + showDialog( + context: context, + builder: (BuildContext _) { + return DeleteDialog( + onDelete: () async { + final isDeleted = await onDelete(true); + if (isDeleted) { + removeAssetFromStack(); + } + }, + ); + }, + ), ); } @@ -150,7 +153,7 @@ class BottomGalleryBar extends ConsumerWidget { onTap: () async { await unStack(); ctx.pop(); - context.maybePop(); + await context.maybePop(); }, title: const Text("viewer_unstack", style: TextStyle(fontWeight: FontWeight.bold)).tr(), ), @@ -178,9 +181,11 @@ class BottomGalleryBar extends ConsumerWidget { void handleEdit() async { final image = Image(image: ImmichImage.imageProvider(asset: asset)); - context.navigator.push( - MaterialPageRoute( - builder: (context) => EditImagePage(asset: asset, image: image, isEdited: false), + unawaited( + context.navigator.push( + MaterialPageRoute( + builder: (context) => EditImagePage(asset: asset, image: image, isEdited: false), + ), ), ); } diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart index f7c80cca3d..d406f29a22 100644 --- a/mobile/lib/widgets/asset_viewer/cast_dialog.dart +++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -93,7 +95,7 @@ class CastDialog extends ConsumerWidget { } if (!isCurrentDevice(deviceName)) { - ref.read(castProvider.notifier).connect(type, deviceObj); + unawaited(ref.read(castProvider.notifier).connect(type, deviceObj)); } }, ); diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart index 0edafa88c5..893e534084 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart @@ -1,11 +1,12 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; class ExifMap extends StatelessWidget { final ExifInfo exifInfo; @@ -68,7 +69,7 @@ class ExifMap extends StatelessWidget { } dPrint(() => 'Opening Map Uri: $uri'); - launchUrl(uri); + unawaited(launchUrl(uri)); }, onCreated: onMapCreated, ); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index e504cf0675..7233cc902e 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -1,19 +1,21 @@ +import 'dart:async'; + 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:immich_mobile/entities/store.entity.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart'; @@ -97,25 +99,27 @@ class ImmichAppBarDialog extends HookConsumerWidget { return; } - showDialog( - context: context, - builder: (BuildContext ctx) { - return ConfirmDialog( - title: "app_bar_signout_dialog_title", - content: "app_bar_signout_dialog_content", - ok: "yes", - onOk: () async { - isLoggingOut.value = true; - await ref.read(authProvider.notifier).logout().whenComplete(() => isLoggingOut.value = false); + unawaited( + showDialog( + context: context, + builder: (BuildContext ctx) { + return ConfirmDialog( + title: "app_bar_signout_dialog_title", + content: "app_bar_signout_dialog_content", + ok: "yes", + onOk: () async { + isLoggingOut.value = true; + await ref.read(authProvider.notifier).logout().whenComplete(() => isLoggingOut.value = false); - ref.read(manualUploadProvider.notifier).cancelBackup(); - ref.read(backupProvider.notifier).cancelBackup(); - ref.read(assetProvider.notifier).clearAllAssets(); - ref.read(websocketProvider.notifier).disconnect(); - context.replaceRoute(const LoginRoute()); - }, - ); - }, + ref.read(manualUploadProvider.notifier).cancelBackup(); + ref.read(backupProvider.notifier).cancelBackup(); + unawaited(ref.read(assetProvider.notifier).clearAllAssets()); + ref.read(websocketProvider.notifier).disconnect(); + unawaited(context.replaceRoute(const LoginRoute())); + }, + ); + }, + ), ); }, trailing: isLoggingOut.value diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index 00366ca580..bc1d608b10 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -6,8 +8,8 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; @@ -54,7 +56,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ref.read(currentUserProvider.notifier).refresh(); } - ref.read(backupProvider.notifier).updateDiskInfo(); + unawaited(ref.read(backupProvider.notifier).updateDiskInfo()); } } } diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index f100b58649..bb987d5bc0 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; @@ -188,17 +189,17 @@ class LoginForm extends HookConsumerWidget { final result = await ref.read(authProvider.notifier).login(emailController.text, passwordController.text); if (result.shouldChangePassword && !result.isAdmin) { - context.pushRoute(const ChangePasswordRoute()); + unawaited(context.pushRoute(const ChangePasswordRoute())); } else { final isBeta = Store.isBetaTimelineEnabled; if (isBeta) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - handleSyncFlow(); + unawaited(handleSyncFlow()); ref.read(websocketProvider.notifier).connect(); - context.replaceRoute(const TabShellRoute()); + unawaited(context.replaceRoute(const TabShellRoute())); return; } - context.replaceRoute(const TabControllerRoute()); + unawaited(context.replaceRoute(const TabControllerRoute())); } } catch (error) { ImmichToast.show( @@ -288,15 +289,15 @@ class LoginForm extends HookConsumerWidget { final permission = ref.watch(galleryPermissionNotifier); final isBeta = Store.isBetaTimelineEnabled; if (!isBeta && (permission.isGranted || permission.isLimited)) { - ref.watch(backupProvider.notifier).resumeBackup(); + unawaited(ref.watch(backupProvider.notifier).resumeBackup()); } if (isBeta) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - handleSyncFlow(); - context.replaceRoute(const TabShellRoute()); + unawaited(handleSyncFlow()); + unawaited(context.replaceRoute(const TabShellRoute())); return; } - context.replaceRoute(const TabControllerRoute()); + unawaited(context.replaceRoute(const TabControllerRoute())); } } catch (error, stack) { log.severe('Error logging in with OAuth: $error', stack); diff --git a/mobile/lib/widgets/map/map_bottom_sheet.dart b/mobile/lib/widgets/map/map_bottom_sheet.dart index baf85e8075..fba9e9a041 100644 --- a/mobile/lib/widgets/map/map_bottom_sheet.dart +++ b/mobile/lib/widgets/map/map_bottom_sheet.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; -import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/utils/draggable_scroll_controller.dart'; +import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; class MapBottomSheet extends HookConsumerWidget { final Stream mapEventStream; @@ -34,7 +34,11 @@ class MapBottomSheet extends HookConsumerWidget { void handleMapEvents(MapEvent event) async { if (event is MapCloseBottomSheet) { - sheetController.animateTo(0.1, duration: const Duration(milliseconds: 200), curve: Curves.linearToEaseOut); + await sheetController.animateTo( + 0.1, + duration: const Duration(milliseconds: 200), + curve: Curves.linearToEaseOut, + ); } } diff --git a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart index 1fefb3dcfa..480665e614 100644 --- a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart +++ b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -42,7 +44,7 @@ class BetaTimelineListTile extends ConsumerWidget { ElevatedButton( onPressed: () async { Navigator.of(context).pop(); - context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: value)]); + unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: value)])); }, child: Text("ok".t(context: context)), ), diff --git a/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart index 9fbc43a429..21e26c8f1f 100644 --- a/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -102,13 +104,13 @@ class LocalNetworkPreference extends HookConsumerWidget { ), ); } else { - saveWifiName(wifiName); + unawaited(saveWifiName(wifiName)); } final serverEndpoint = ref.read(authProvider.notifier).getServerEndpoint(); if (serverEndpoint != null) { - saveLocalEndpoint(serverEndpoint); + unawaited(saveLocalEndpoint(serverEndpoint)); } } diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index d03e493843..996170b518 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -53,7 +53,7 @@ void main() { }); tearDown(() async { - sut.dispose(); + unawaited(sut.dispose()); await controller.close(); }); @@ -129,7 +129,7 @@ void main() { final stream = sut.watch(StoreKey.accessToken); final events = [_kAccessToken, _kAccessToken.toUpperCase(), null, _kAccessToken.toLowerCase()]; - expectLater(stream, emitsInOrder(events)); + unawaited(expectLater(stream, emitsInOrder(events))); for (final event in events) { valueController.add(event); diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index f6424beabc..18d41e32e0 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -99,7 +101,7 @@ void main() { final count = await db.storeValues.count(); expect(count, isNot(isZero)); await sut.deleteAll(); - expectLater(await db.storeValues.count(), isZero); + unawaited(expectLater(await db.storeValues.count(), isZero)); }); }); @@ -124,29 +126,31 @@ void main() { test('watch()', () async { final stream = sut.watch(StoreKey.version); - expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10])); + unawaited(expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10]))); await pumpEventQueue(); await sut.upsert(StoreKey.version, _kTestVersion + 10); }); test('watchAll()', () async { final stream = sut.watchAll(); - expectLater( - stream, - emitsInOrder([ - [ - const StoreDto(StoreKey.version, _kTestVersion), - StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), - const StoreDto(StoreKey.accessToken, _kTestAccessToken), - const StoreDto(StoreKey.colorfulInterface, _kTestColorfulInterface), - ], - [ - const StoreDto(StoreKey.version, _kTestVersion + 10), - StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), - const StoreDto(StoreKey.accessToken, _kTestAccessToken), - const StoreDto(StoreKey.colorfulInterface, _kTestColorfulInterface), - ], - ]), + unawaited( + expectLater( + stream, + emitsInOrder([ + [ + const StoreDto(StoreKey.version, _kTestVersion), + StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), + const StoreDto(StoreKey.accessToken, _kTestAccessToken), + const StoreDto(StoreKey.colorfulInterface, _kTestColorfulInterface), + ], + [ + const StoreDto(StoreKey.version, _kTestVersion + 10), + StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), + const StoreDto(StoreKey.accessToken, _kTestAccessToken), + const StoreDto(StoreKey.colorfulInterface, _kTestColorfulInterface), + ], + ]), + ), ); await sut.upsert(StoreKey.version, _kTestVersion + 10); }); diff --git a/mobile/test/modules/activity/activities_page_test.dart b/mobile/test/modules/activity/activities_page_test.dart index 05eac98111..39350530ea 100644 --- a/mobile/test/modules/activity/activities_page_test.dart +++ b/mobile/test/modules/activity/activities_page_test.dart @@ -64,9 +64,9 @@ void main() { TestUtils.init(); db = await TestUtils.initIsar(); await StoreService.init(storeRepository: IsarStoreRepository(db)); - Store.put(StoreKey.currentUser, UserStub.admin); - Store.put(StoreKey.serverEndpoint, ''); - Store.put(StoreKey.accessToken, ''); + await Store.put(StoreKey.currentUser, UserStub.admin); + await Store.put(StoreKey.serverEndpoint, ''); + await Store.put(StoreKey.accessToken, ''); }); setUp(() async { diff --git a/mobile/test/modules/activity/activity_text_field_test.dart b/mobile/test/modules/activity/activity_text_field_test.dart index 1163330c54..8f28b7f28e 100644 --- a/mobile/test/modules/activity/activity_text_field_test.dart +++ b/mobile/test/modules/activity/activity_text_field_test.dart @@ -35,8 +35,8 @@ void main() { TestUtils.init(); db = await TestUtils.initIsar(); await StoreService.init(storeRepository: IsarStoreRepository(db)); - Store.put(StoreKey.currentUser, UserStub.admin); - Store.put(StoreKey.serverEndpoint, ''); + await Store.put(StoreKey.currentUser, UserStub.admin); + await Store.put(StoreKey.serverEndpoint, ''); }); setUp(() { diff --git a/mobile/test/modules/activity/activity_tile_test.dart b/mobile/test/modules/activity/activity_tile_test.dart index eb4bb25848..718dfcce21 100644 --- a/mobile/test/modules/activity/activity_tile_test.dart +++ b/mobile/test/modules/activity/activity_tile_test.dart @@ -31,9 +31,9 @@ void main() { db = await TestUtils.initIsar(); // For UserCircleAvatar await StoreService.init(storeRepository: IsarStoreRepository(db)); - Store.put(StoreKey.currentUser, UserStub.admin); - Store.put(StoreKey.serverEndpoint, ''); - Store.put(StoreKey.accessToken, ''); + await Store.put(StoreKey.currentUser, UserStub.admin); + await Store.put(StoreKey.serverEndpoint, ''); + await Store.put(StoreKey.accessToken, ''); }); setUp(() { diff --git a/mobile/test/modules/utils/async_mutex_test.dart b/mobile/test/modules/utils/async_mutex_test.dart index d50567721b..08cafeb307 100644 --- a/mobile/test/modules/utils/async_mutex_test.dart +++ b/mobile/test/modules/utils/async_mutex_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; @@ -7,11 +9,11 @@ void main() { AsyncMutex lock = AsyncMutex(); List events = []; expect(0, lock.enqueued); - lock.run(() => Future.delayed(const Duration(milliseconds: 10), () => events.add(1))); + unawaited(lock.run(() => Future.delayed(const Duration(milliseconds: 10), () => events.add(1)))); expect(1, lock.enqueued); - lock.run(() => Future.delayed(const Duration(milliseconds: 3), () => events.add(2))); + unawaited(lock.run(() => Future.delayed(const Duration(milliseconds: 3), () => events.add(2)))); expect(2, lock.enqueued); - lock.run(() => Future.delayed(const Duration(milliseconds: 1), () => events.add(3))); + unawaited(lock.run(() => Future.delayed(const Duration(milliseconds: 1), () => events.add(3)))); expect(3, lock.enqueued); await lock.run(() => Future.delayed(const Duration(milliseconds: 10), () => events.add(4))); expect(0, lock.enqueued); From 9e3b4ef3db2b3fc8e2ec21a1e6eccf805b219b61 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:54:41 +0530 Subject: [PATCH 031/105] chore(dep): bump flutter to 3.35.7 (#23287) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mise.toml | 2 +- mobile/.fvmrc | 2 +- mobile/.vscode/settings.json | 2 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 2 +- server/Dockerfile.dev | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mise.toml b/mise.toml index d3af8bfeb9..f19da88ebe 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,6 @@ [tools] node = "22.20.0" -flutter = "3.35.6" +flutter = "3.35.7" pnpm = "10.18.1" terragrunt = "0.91.2" opentofu = "1.10.6" diff --git a/mobile/.fvmrc b/mobile/.fvmrc index d70a803f6e..e8b4151592 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.35.6" + "flutter": "3.35.7" } \ No newline at end of file diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index f1d5ac8fd5..3092c4565f 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.35.6", + "dart.flutterSdkPath": ".fvm/versions/3.35.7", "dart.lineLength": 120, "[dart]": { "editor.rulers": [120] diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 8dc7076b48..ec2742f980 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -2188,4 +2188,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.6" + flutter: ">=3.35.7" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2883b38e94..c9af2fee31 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 2.1.0+3022 environment: sdk: '>=3.8.0 <4.0.0' - flutter: 3.35.6 + flutter: 3.35.7 dependencies: async: ^2.13.0 diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 73afbe1a04..862fef8385 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -61,7 +61,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ # Flutter SDK # https://flutter.dev/docs/development/tools/sdk/releases?tab=linux ENV FLUTTER_CHANNEL="stable" -ENV FLUTTER_VERSION="3.35.6" +ENV FLUTTER_VERSION="3.35.7" ENV FLUTTER_HOME=/flutter ENV PATH=${PATH}:${FLUTTER_HOME}/bin From 44149d187f960224f76f2eee870df508f40a46a2 Mon Sep 17 00:00:00 2001 From: Thomas Stachl <286093+tstachl@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:46:54 +0300 Subject: [PATCH 032/105] feat(server): enhance metadata reading for video files (#23258) --- server/src/repositories/metadata.repository.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 6073ddcb22..32882de0e0 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { mimeTypes } from 'src/utils/mime-types'; interface ExifDuration { Value: number; @@ -103,7 +104,8 @@ export class MetadataRepository { } readTags(path: string): Promise { - return this.exiftool.read(path).catch((error) => { + const args = mimeTypes.isVideo(path) ? ['-ee'] : []; + return this.exiftool.read(path, args).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); return {}; }) as Promise; From 698531d6e00d805ec2a6701e65c9dd8ae8457df3 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 27 Oct 2025 16:32:52 -0500 Subject: [PATCH 033/105] feat: improve UI for resolving duplication detection (#23145) * feat: improve UI for resolving duplication detection * pr feedback --- i18n/en.json | 5 + .../duplicates/duplicate-asset.svelte | 107 ++++++++++++------ .../duplicates-compare-control.svelte | 14 ++- .../utilities-page/duplicates/info-row.svelte | 23 ++++ .../[[assetId=id]]/+page.svelte | 8 +- 5 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 web/src/lib/components/utilities-page/duplicates/info-row.svelte diff --git a/i18n/en.json b/i18n/en.json index 13df73c03c..d0a4da3de5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -791,6 +791,7 @@ "daily_title_text_date_year": "E, MMM dd, yyyy", "dark": "Dark", "dark_theme": "Toggle dark theme", + "date": "Date", "date_after": "Date after", "date_and_time": "Date and Time", "date_before": "Date before", @@ -1100,6 +1101,7 @@ "features_setting_description": "Manage the app features", "file_name": "File name", "file_name_or_extension": "File name or extension", + "file_size": "File size", "filename": "Filename", "filetype": "Filetype", "filter": "Filter", @@ -1263,6 +1265,7 @@ "local_media_summary": "Local Media Summary", "local_network": "Local network", "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location": "Location", "location_permission": "Location permission", "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current Wi-Fi network's name", "location_picker_choose_on_map": "Choose on map", @@ -1695,6 +1698,7 @@ "reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data", "reset_sqlite_success": "Successfully reset the SQLite database", "reset_to_default": "Reset to default", + "resolution": "Resolution", "resolve_duplicates": "Resolve duplicates", "resolved_all_duplicates": "Resolved all duplicates", "restore": "Restore", @@ -2021,6 +2025,7 @@ "theme_setting_three_stage_loading_title": "Enable three-stage loading", "they_will_be_merged_together": "They will be merged together", "third_party_resources": "Third-Party Resources", + "time": "Time", "time_based_memories": "Time-based memories", "timeline": "Timeline", "timezone": "Timezone", diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 9b2ee94cc9..8a8395d792 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -9,6 +9,9 @@ import { mdiBookmarkOutline, mdiCalendar, + mdiClock, + mdiFile, + mdiFitToScreen, mdiHeart, mdiImageMultipleOutline, mdiImageOutline, @@ -16,15 +19,17 @@ mdiMapMarkerOutline, } from '@mdi/js'; import { t } from 'svelte-i18n'; + import InfoRow from './info-row.svelte'; interface Props { + assets: AssetResponseDto[]; asset: AssetResponseDto; isSelected: boolean; onSelectAsset: (asset: AssetResponseDto) => void; onViewAsset: (asset: AssetResponseDto) => void; } - let { asset, isSelected, onSelectAsset, onViewAsset }: Props = $props(); + let { assets, asset, isSelected, onSelectAsset, onViewAsset }: Props = $props(); let isFromExternalLibrary = $derived(!!asset.libraryId); let assetData = $derived(JSON.stringify(asset, null, 2)); @@ -37,13 +42,46 @@ ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) : fromISODateTimeUTC(asset.localDateTime), ); + + const isDifferent = (getter: (asset: AssetResponseDto) => string | undefined): boolean => { + return new Set(assets.map((asset) => getter(asset))).size > 1; + }; + + const hasDifferentValues = $derived({ + fileName: isDifferent((a) => a.originalFileName), + fileSize: isDifferent((a) => getFileSize(a)), + resolution: isDifferent((a) => getAssetResolution(a)), + date: isDifferent((a) => { + const tz = a.exifInfo?.timeZone; + const dt = + tz && a.exifInfo?.dateTimeOriginal + ? fromISODateTime(a.exifInfo.dateTimeOriginal, tz) + : fromISODateTimeUTC(a.localDateTime); + return dt?.toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale: $locale }); + }), + time: isDifferent((a) => { + const tz = a.exifInfo?.timeZone; + const dt = + tz && a.exifInfo?.dateTimeOriginal + ? fromISODateTime(a.exifInfo.dateTimeOriginal, tz) + : fromISODateTimeUTC(a.localDateTime); + return dt?.toLocaleString( + { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + timeZoneName: tz ? 'shortOffset' : undefined, + }, + { locale: $locale }, + ); + }), + location: isDifferent( + (a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown', + ), + }); -
+
-
- -
- {asset.originalFileName}
- {getAssetResolution(asset)} - {getFileSize(asset)} -
-
-
- + + {asset.originalFileName} + + + + {getFileSize(asset)} + + + + {getAssetResolution(asset)} + + + {#if dateTime} {dateTime.toLocaleString( { @@ -128,12 +170,18 @@ }, { locale: $locale }, )} + {:else} + {$t('unknown')} + {/if} + + + {#if dateTime} {dateTime.toLocaleString( { - // weekday: 'short', hour: 'numeric', minute: '2-digit', + second: '2-digit', timeZoneName: timeZone ? 'shortOffset' : undefined, }, { locale: $locale }, @@ -141,27 +189,22 @@ {:else} {$t('unknown')} {/if} -
+ -
- + {#if locationParts.length > 0} {locationParts.join(', ')} {:else} {$t('unknown')} {/if} -
-
- + + + {#await getAllAlbums({ assetId: asset.id })} {$t('scanning_for_album')} {:then albums} - {#if albums.length === 0} - {$t('not_in_any_album')} - {:else} - {$t('in_albums', { values: { count: albums.length } })} - {/if} + {$t('in_albums', { values: { count: albums.length } })} {/await} -
+
diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 5ec0837423..3509f07fb0 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -117,7 +117,7 @@ ]} /> -
+
@@ -139,7 +139,7 @@
-
- {#each assets as asset (asset.id)} - - {/each} +
+
+ {#each assets as asset (asset.id)} + + {/each} +
diff --git a/web/src/lib/components/utilities-page/duplicates/info-row.svelte b/web/src/lib/components/utilities-page/duplicates/info-row.svelte new file mode 100644 index 0000000000..e237d70feb --- /dev/null +++ b/web/src/lib/components/utilities-page/duplicates/info-row.svelte @@ -0,0 +1,23 @@ + + +
+ +
+ + {@render children?.()} + +
+
diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index adc0f679cb..19f254a8cd 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -281,8 +281,8 @@ handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)} onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)} /> -
-
+
+
-

{duplicatesIndex + 1}/{duplicates.length.toLocaleString($locale)}

+

+ {duplicatesIndex + 1} / {duplicates.length.toLocaleString($locale)} +

{/if} + Date: Tue, 28 Oct 2025 12:30:12 +0000 Subject: [PATCH 035/105] fix: ml container tags incorrect for different hardware builds (#23313) Co-authored-by: bo0tzz --- .github/workflows/docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0c4dc9765c..81b11c4aca 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -132,7 +132,7 @@ jobs: suffixes: '-rocm' platforms: linux/amd64 runner-mapping: '{"linux/amd64": "mich"}' - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@a667ef0a5cf3ff1ff1e41be52d3fe326b24e3a00 # multi-runner-build-workflow-v1.1.3 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1 permissions: contents: read actions: read @@ -155,7 +155,7 @@ jobs: name: Build and Push Server needs: pre-job if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }} - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@a667ef0a5cf3ff1ff1e41be52d3fe326b24e3a00 # multi-runner-build-workflow-v1.1.3 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1 permissions: contents: read actions: read From 62ed5fe27f2487350d1cccca442d33e37cf9d993 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:30:42 +0000 Subject: [PATCH 036/105] chore(deps): update base-image to v202510281104 (major) (#23315) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- server/Dockerfile.dev | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 2ff62f9ab4..54077d80ce 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202510092146@sha256:124ec9659cba4a206924de5e3691f84acde16d75fa2b10b7007542424b696b96 AS builder +FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -48,7 +48,7 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ pnpm --filter @immich/sdk --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned -FROM ghcr.io/immich-app/base-server-prod:202510092146@sha256:c39b9ad949e7777bce415e6931334aeff7331e04cb7f9df93f9ae44f6ff36b9e +FROM ghcr.io/immich-app/base-server-prod:202510281104@sha256:84f8f3eb4cfafc5e624235f7db703e1222fd60831bef1d488d8d8cad2be5023d WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 862fef8385..93a4f197ea 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202510092146@sha256:124ec9659cba4a206924de5e3691f84acde16d75fa2b10b7007542424b696b96 AS dev +FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS dev ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ From 46a4dce16b6251db80295e600b173b30d1222a56 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:32:37 +0000 Subject: [PATCH 037/105] chore(deps): update grafana/grafana docker tag to v12.2.1 (#23312) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 22bb0f3444..a8c0de7454 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -95,7 +95,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:12.1.1-ubuntu@sha256:d1da838234ff2de93e0065ee1bf0e66d38f948dcc5d718c25fa6237e14b4424a + image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a volumes: - grafana-data:/var/lib/grafana From f72bcc8a8f984e7dd7553353f1f0d0cfba7fd1f1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:36:19 +0000 Subject: [PATCH 038/105] chore(deps): update node.js to v22.21.0 (#23314) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/.nvmrc | 2 +- cli/.nvmrc | 2 +- cli/package.json | 2 +- docs/.nvmrc | 2 +- docs/package.json | 2 +- e2e/.nvmrc | 2 +- e2e/package.json | 2 +- mise.toml | 2 +- open-api/typescript-sdk/.nvmrc | 2 +- open-api/typescript-sdk/package.json | 2 +- server/.nvmrc | 2 +- server/package.json | 2 +- web/.nvmrc | 2 +- web/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/.nvmrc b/.github/.nvmrc index 442c7587a9..aa50a62f21 100644 --- a/.github/.nvmrc +++ b/.github/.nvmrc @@ -1 +1 @@ -22.20.0 +22.21.0 diff --git a/cli/.nvmrc b/cli/.nvmrc index 442c7587a9..aa50a62f21 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -22.20.0 +22.21.0 diff --git a/cli/package.json b/cli/package.json index c3db88a7ac..0976ac6ecd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -69,6 +69,6 @@ "micromatch": "^4.0.8" }, "volta": { - "node": "22.20.0" + "node": "22.21.0" } } diff --git a/docs/.nvmrc b/docs/.nvmrc index 442c7587a9..aa50a62f21 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -22.20.0 +22.21.0 diff --git a/docs/package.json b/docs/package.json index f45a4ec256..bb67047396 100644 --- a/docs/package.json +++ b/docs/package.json @@ -57,6 +57,6 @@ "node": ">=20" }, "volta": { - "node": "22.20.0" + "node": "22.21.0" } } diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 442c7587a9..aa50a62f21 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -22.20.0 +22.21.0 diff --git a/e2e/package.json b/e2e/package.json index 56d982682d..6b8eb446b6 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -52,6 +52,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "22.20.0" + "node": "22.21.0" } } diff --git a/mise.toml b/mise.toml index f19da88ebe..75081f44cc 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -node = "22.20.0" +node = "22.21.0" flutter = "3.35.7" pnpm = "10.18.1" terragrunt = "0.91.2" diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 442c7587a9..aa50a62f21 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -22.20.0 +22.21.0 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index c179d1504e..459e619a02 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "22.20.0" + "node": "22.21.0" } } diff --git a/server/.nvmrc b/server/.nvmrc index 442c7587a9..aa50a62f21 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -22.20.0 +22.21.0 diff --git a/server/package.json b/server/package.json index 694817591f..7ff18ca2e1 100644 --- a/server/package.json +++ b/server/package.json @@ -161,7 +161,7 @@ "vitest": "^3.0.0" }, "volta": { - "node": "22.20.0" + "node": "22.21.0" }, "overrides": { "sharp": "^0.34.4" diff --git a/web/.nvmrc b/web/.nvmrc index 442c7587a9..aa50a62f21 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -22.20.0 +22.21.0 diff --git a/web/package.json b/web/package.json index fb672af63a..dfcd7ef28a 100644 --- a/web/package.json +++ b/web/package.json @@ -106,6 +106,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "22.20.0" + "node": "22.21.0" } } From fb97d9f4d976a1af23231240a32a1f5e1d81db5c Mon Sep 17 00:00:00 2001 From: Thomas Stachl <286093+tstachl@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:15:35 +0300 Subject: [PATCH 039/105] fix(web): disable picture-in-picture on video viewer (#23318) --- web/src/lib/components/asset-viewer/video-native-viewer.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 2ccfb59243..92c467bc1e 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -140,6 +140,7 @@ autoplay={$autoPlayVideo} playsinline controls + disablePictureInPicture class="h-full object-contain" {...useSwipe(onSwipe)} oncanplay={(e) => handleCanPlay(e.currentTarget)} From 74f2c10a5a9f57b697a40c4720903589c9e01dd0 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 28 Oct 2025 09:19:40 -0500 Subject: [PATCH 040/105] fix: make hitbox on app bar dialog bigger (#23316) --- .../common/app_bar_dialog/app_bar_dialog.dart | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 7233cc902e..c6a557964d 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -46,19 +46,24 @@ class ImmichAppBarDialog extends HookConsumerWidget { }, []); buildTopRow() { - return Stack( - children: [ - Align( - alignment: Alignment.topLeft, - child: InkWell(onTap: () => context.pop(), child: const Icon(Icons.close, size: 20)), - ), - Center( - child: Image.asset( - context.isDarkTheme ? 'assets/immich-text-dark.png' : 'assets/immich-text-light.png', - height: 16, + return SizedBox( + height: 56, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + IconButton(onPressed: () => context.pop(), icon: const Icon(Icons.close, size: 20)), + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Image.asset( + context.isDarkTheme ? 'assets/immich-text-dark.png' : 'assets/immich-text-light.png', + height: 16, + ), + ), ), - ), - ], + ], + ), ); } @@ -260,7 +265,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container(padding: const EdgeInsets.all(20), child: buildTopRow()), + Container(padding: const EdgeInsets.symmetric(horizontal: 8), child: buildTopRow()), const AppBarProfileInfoBox(), buildStorageInformation(), const AppBarServerInfo(), From e0c2cdddd44fb7bb64d9d7960ff814293ebe521e Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 28 Oct 2025 11:52:01 -0500 Subject: [PATCH 041/105] feat: show "appears in" albums on asset viewer bottom sheet (#21925) * feat: show "appears in" albums on asset viewer bottom sheet fix: multiple RemoteAlbumPages in navigation stack this also allows us to not have to set the current album before navigating to RemoteAlbumPage chore: clarification comments handle nested album pages fix: hide "appears in" when an asset is not in any albums fix: way more bottom padding for some reason we can't query the safe area here :/ * fix: bottom sheet now is usable when navigating to another asset viewer * fix: rebase conflict * fix: restore ancestors album to currentRemoteAlbumProvider when popping * fix: view flashing when dismissing a album viewer * chore: code review changes * fix: styling and padding * chore: rework currentRemoteAlbumProvider to be scoped by the Remote album page * fix: override remote album provider on required pages * chore: convert query to all SQL calls instead of matching in Dart * fix: album query * fix: unawaited future * Update deep_link.service.dart --------- Co-authored-by: Alex --- .../domain/services/remote_album.service.dart | 4 + .../repositories/remote_album.repository.dart | 55 +++++++++ .../pages/drift_activities.page.dart | 113 +++++++++--------- .../presentation/pages/drift_album.page.dart | 2 - .../pages/drift_album_options.page.dart | 92 +++++++------- .../pages/drift_create_album.page.dart | 2 - .../pages/drift_remote_album.page.dart | 49 +++----- .../widgets/album/album_selector.widget.dart | 43 +------ .../widgets/album/album_tile.dart | 51 ++++++++ .../asset_viewer/asset_viewer.page.dart | 18 ++- .../asset_viewer/bottom_sheet.widget.dart | 67 ++++++++++- .../widgets/timeline/fixed/segment.model.dart | 2 + .../infrastructure/album.provider.dart | 5 + .../current_album.provider.dart | 32 ++--- mobile/lib/routing/router.dart | 2 +- mobile/lib/routing/router.gr.dart | 66 ++++++++-- mobile/lib/services/deep_link.service.dart | 5 - .../album/remote_album_shared_user_icons.dart | 2 +- .../common/remote_album_sliver_app_bar.dart | 3 +- 19 files changed, 401 insertions(+), 212 deletions(-) create mode 100644 mobile/lib/presentation/widgets/album/album_tile.dart diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 13dfadb8d8..67e91188e2 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -164,6 +164,10 @@ class RemoteAlbumService { return _repository.getCount(); } + Future> getAlbumsContainingAsset(String assetId) { + return _repository.getAlbumsContainingAsset(assetId); + } + Future> _sortByNewestAsset(List albums) async { // map album IDs to their newest asset dates final Map> assetTimestampFutures = {}; diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 0526cfb7aa..d7d4a250ad 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -382,6 +382,61 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get(); } + + Future> getAlbumsContainingAsset(String assetId) async { + // Note: this needs to be 2 queries as the where clause filtering causes the assetCount to always be 1 + final albumIdsQuery = _db.remoteAlbumAssetEntity.selectOnly() + ..addColumns([_db.remoteAlbumAssetEntity.albumId]) + ..where(_db.remoteAlbumAssetEntity.assetId.equals(assetId)); + + final albumIds = await albumIdsQuery.map((row) => row.read(_db.remoteAlbumAssetEntity.albumId)!).get(); + + if (albumIds.isEmpty) { + return []; + } + + final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true); + final query = + _db.remoteAlbumEntity.select().join([ + leftOuterJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.albumId.equalsExp(_db.remoteAlbumEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId), + useColumns: false, + ), + leftOuterJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAlbumUserEntity, + _db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id), + useColumns: false, + ), + ]) + ..where(_db.remoteAlbumEntity.id.isIn(albumIds) & _db.remoteAssetEntity.deletedAt.isNull()) + ..addColumns([assetCount]) + ..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)]) + ..addColumns([_db.userEntity.name]) + ..groupBy([_db.remoteAlbumEntity.id]); + + return query + .map( + (row) => row + .readTable(_db.remoteAlbumEntity) + .toDto( + ownerName: row.read(_db.userEntity.name) ?? '', + isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0, + assetCount: row.read(assetCount) ?? 0, + ), + ) + .get(); + } } extension on RemoteAlbumEntityData { diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index d8f8799f7d..731bcb5dba 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -3,6 +3,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -18,12 +19,13 @@ import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { - const DriftActivitiesPage({super.key}); + final RemoteAlbum album; + + const DriftActivitiesPage({super.key, required this.album}); @override Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider)!; - final asset = ref.read(currentAssetNotifier) as RemoteAsset?; + final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; final user = ref.watch(currentUserProvider); final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); @@ -43,62 +45,65 @@ class DriftActivitiesPage extends HookConsumerWidget { scrollToBottom(); } - return Scaffold( - appBar: AppBar( - title: asset == null ? Text(album.name) : null, - actions: [const LikeActivityActionButton(menuItem: true)], - actionsPadding: const EdgeInsets.only(right: 8), - ), - body: activities.widgetWhen( - onData: (data) { - final liked = data.firstWhereOrNull( - (a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id, - ); + return ProviderScope( + overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], + child: Scaffold( + appBar: AppBar( + title: asset == null ? Text(album.name) : null, + actions: [const LikeActivityActionButton(menuItem: true)], + actionsPadding: const EdgeInsets.only(right: 8), + ), + body: activities.widgetWhen( + onData: (data) { + final liked = data.firstWhereOrNull( + (a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id, + ); - return SafeArea( - child: Stack( - children: [ - ListView.builder( - controller: listViewScrollController, - itemCount: data.length + 1, - itemBuilder: (context, index) { - if (index == data.length) { - return const SizedBox(height: 80); - } - final activity = data[index]; - final canDelete = activity.user.id == user?.id || album.ownerId == user?.id; - return Padding( - padding: const EdgeInsets.all(5), - child: DismissibleActivity( - activity.id, - ActivityTile(activity), - onDismiss: canDelete - ? (activityId) async => await activityNotifier.removeActivity(activity.id) - : null, + return SafeArea( + child: Stack( + children: [ + ListView.builder( + controller: listViewScrollController, + itemCount: data.length + 1, + itemBuilder: (context, index) { + if (index == data.length) { + return const SizedBox(height: 80); + } + final activity = data[index]; + final canDelete = activity.user.id == user?.id || album.ownerId == user?.id; + return Padding( + padding: const EdgeInsets.all(5), + child: DismissibleActivity( + activity.id, + ActivityTile(activity), + onDismiss: canDelete + ? (activityId) async => await activityNotifier.removeActivity(activity.id) + : null, + ), + ); + }, + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: context.scaffoldBackgroundColor, + border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)), + ), + child: DriftActivityTextField( + isEnabled: album.isActivityEnabled, + likeId: liked?.id, + onSubmit: onAddComment, ), - ); - }, - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)), - ), - child: DriftActivityTextField( - isEnabled: album.isActivityEnabled, - likeId: liked?.id, - onSubmit: onAddComment, ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), + resizeToAvoidBottomInset: true, ), - resizeToAvoidBottomInset: true, ); } } diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index 0835c741ad..a159c6c54a 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; @@ -43,7 +42,6 @@ class _DriftAlbumsPageState extends ConsumerState { ), AlbumSelector( onAlbumSelected: (album) { - ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); context.router.push(RemoteAlbumRoute(album: album)); }, ), diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 2116e5c5cc..9db6e98613 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; @@ -22,15 +23,11 @@ import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @RoutePage() class DriftAlbumOptionsPage extends HookConsumerWidget { - const DriftAlbumOptionsPage({super.key}); + final RemoteAlbum album; + const DriftAlbumOptionsPage({super.key, required this.album}); @override Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider); - if (album == null) { - return const SizedBox(); - } - final sharedUsersAsync = ref.watch(remoteAlbumSharedUsersProvider(album.id)); final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.isActivityEnabled); @@ -191,48 +188,51 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { ); } - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: () => context.maybePop(null), + return ProviderScope( + overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: () => context.maybePop(null), + ), + centerTitle: true, + title: Text("options".t(context: context)), ), - centerTitle: true, - title: Text("options".t(context: context)), - ), - body: ListView( - children: [ - const SizedBox(height: 8), - if (isOwner) - SwitchListTile.adaptive( - value: activityEnabled.value, - onChanged: (bool value) async { - activityEnabled.value = value; - await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value); - }, - activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor, - dense: true, - title: Text( - "comments_and_likes", - style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), - ).t(context: context), - subtitle: Text( - "let_others_respond", - style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ).t(context: context), - ), - buildSectionTitle("shared_album_section_people_title".t(context: context)), - if (isOwner) ...[ - ListTile( - leading: const Icon(Icons.person_add_rounded), - title: Text("invite_people".t(context: context)), - onTap: () async => addUsers(), - ), - const Divider(indent: 16), + body: ListView( + children: [ + const SizedBox(height: 8), + if (isOwner) + SwitchListTile.adaptive( + value: activityEnabled.value, + onChanged: (bool value) async { + activityEnabled.value = value; + await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value); + }, + activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor, + dense: true, + title: Text( + "comments_and_likes", + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), + ).t(context: context), + subtitle: Text( + "let_others_respond", + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ).t(context: context), + ), + buildSectionTitle("shared_album_section_people_title".t(context: context)), + if (isOwner) ...[ + ListTile( + leading: const Icon(Icons.person_add_rounded), + title: Text("invite_people".t(context: context)), + onTap: () async => addUsers(), + ), + const Divider(indent: 16), + ], + buildOwnerInfo(), + buildSharedUsersList(), ], - buildOwnerInfo(), - buildSharedUsersList(), - ], + ), ), ); } diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index 2e263ba1db..57e5cb09a9 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -8,7 +8,6 @@ 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/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; @@ -180,7 +179,6 @@ class _DriftCreateAlbumPageState extends ConsumerState { ); if (album != null) { - ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); unawaited(context.replaceRoute(RemoteAlbumRoute(album: album))); } } diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 2d70978ea5..9a52f28deb 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -168,7 +168,7 @@ class _RemoteAlbumPageState extends ConsumerState { } Future showActivity(BuildContext context) async { - unawaited(context.pushRoute(const DriftActivitiesRoute())); + unawaited(context.pushRoute(DriftActivitiesRoute(album: _album))); } Future showOptionSheet(BuildContext context) async { @@ -224,7 +224,7 @@ class _RemoteAlbumPageState extends ConsumerState { : null, onShowOptions: () { context.pop(); - context.pushRoute(const DriftAlbumOptionsRoute()); + context.pushRoute(DriftAlbumOptionsRoute(album: _album)); }, ); }, @@ -237,35 +237,24 @@ class _RemoteAlbumPageState extends ConsumerState { final user = ref.watch(currentUserProvider); final isOwner = user != null ? user.id == _album.ownerId : false; - return PopScope( - onPopInvokedWithResult: (didPop, _) { - if (didPop) { - Future.microtask(() { - if (mounted) { - ref.read(currentRemoteAlbumProvider.notifier).dispose(); - ref.read(remoteAlbumProvider.notifier).refresh(); - } - }); - } - }, - child: ProviderScope( - overrides: [ - timelineServiceProvider.overrideWith((ref) { - final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id); - ref.onDispose(timelineService.dispose); - return timelineService; - }), - ], - child: Timeline( - appBar: RemoteAlbumSliverAppBar( - icon: Icons.photo_album_outlined, - onShowOptions: () => showOptionSheet(context), - onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null, - onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null, - onActivity: () => showActivity(context), - ), - bottomSheet: RemoteAlbumBottomSheet(album: _album), + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith((ref) { + final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + currentRemoteAlbumScopedProvider.overrideWithValue(_album), + ], + child: Timeline( + appBar: RemoteAlbumSliverAppBar( + icon: Icons.photo_album_outlined, + onShowOptions: () => showOptionSheet(context), + onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null, + onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null, + onActivity: () => showActivity(context), ), + bottomSheet: RemoteAlbumBottomSheet(album: _album), ), ); } diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index cbac6c8b93..0d5b9a7636 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -12,10 +12,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; -import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -516,38 +515,6 @@ class _AlbumList extends ConsumerWidget { sliver: SliverList.builder( itemBuilder: (_, index) { final album = albums[index]; - final albumTile = LargeLeadingTile( - title: Text( - album.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - subtitle: Text( - '${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}', - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - onTap: () => onAlbumSelected(album), - leadingPadding: const EdgeInsets.only(right: 16), - leading: album.thumbnailAssetId != null - ? ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(15)), - child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), - ) - : SizedBox( - width: 80, - height: 80, - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainer, - borderRadius: const BorderRadius.all(Radius.circular(16)), - border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1), - ), - child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey), - ), - ), - ); final isOwner = album.ownerId == userId; if (isOwner) { @@ -576,11 +543,14 @@ class _AlbumList extends ConsumerWidget { onDismissed: (direction) async { await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id); }, - child: albumTile, + child: AlbumTile(album: album, isOwner: isOwner, onAlbumSelected: onAlbumSelected), ), ); } else { - return Padding(padding: const EdgeInsets.only(bottom: 8.0), child: albumTile); + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: AlbumTile(album: album, isOwner: isOwner, onAlbumSelected: onAlbumSelected), + ); } }, itemCount: albums.length, @@ -709,7 +679,6 @@ class AddToAlbumHeader extends ConsumerWidget { return; } - ref.read(currentRemoteAlbumProvider.notifier).setAlbum(newAlbum); ref.read(multiSelectProvider.notifier).reset(); unawaited(context.pushRoute(RemoteAlbumRoute(album: newAlbum))); } diff --git a/mobile/lib/presentation/widgets/album/album_tile.dart b/mobile/lib/presentation/widgets/album/album_tile.dart new file mode 100644 index 0000000000..561b018ef8 --- /dev/null +++ b/mobile/lib/presentation/widgets/album/album_tile.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; + +class AlbumTile extends StatelessWidget { + const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected}); + + final RemoteAlbum album; + final bool isOwner; + final Function(RemoteAlbum)? onAlbumSelected; + + @override + Widget build(BuildContext context) { + return LargeLeadingTile( + title: Text( + album.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + subtitle: Text( + '${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${isOwner ? 'owned'.t(context: context) : 'shared_by_user'.t(context: context, args: {'user': album.ownerName})}', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + onTap: () => onAlbumSelected?.call(album), + leadingPadding: const EdgeInsets.only(right: 16), + leading: album.thumbnailAssetId != null + ? ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15)), + child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), + ) + : SizedBox( + width: 80, + height: 80, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(16)), + border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1), + ), + child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 2e3009d934..f8a2c37ccd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; @@ -13,6 +14,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -20,7 +22,6 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widge import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; @@ -28,6 +29,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/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/widgets/common/immich_loading_indicator.dart'; @@ -39,15 +41,25 @@ class AssetViewerPage extends StatelessWidget { final int initialIndex; final TimelineService timelineService; final int? heroOffset; + final RemoteAlbum? currentAlbum; - const AssetViewerPage({super.key, required this.initialIndex, required this.timelineService, this.heroOffset}); + const AssetViewerPage({ + super.key, + required this.initialIndex, + required this.timelineService, + this.heroOffset, + this.currentAlbum, + }); @override Widget build(BuildContext context) { // This is necessary to ensure that the timeline service is available // since the Timeline and AssetViewer are on different routes / Widget subtrees. return ProviderScope( - overrides: [timelineServiceProvider.overrideWithValue(timelineService)], + overrides: [ + timelineServiceProvider.overrideWithValue(timelineService), + currentRemoteAlbumScopedProvider.overrideWithValue(currentAlbum), + ], child: AssetViewer(initialIndex: initialIndex, heroOffset: heroOffset), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index c4fbd2cfe3..00e16fa870 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,11 +12,14 @@ import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; @@ -20,6 +27,7 @@ import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -132,6 +140,60 @@ class _AssetDetailBottomSheet extends ConsumerWidget { await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); } + Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { + final isRemote = ref.watch(currentAssetNotifier)?.hasRemote ?? false; + if (!isRemote) { + return const SizedBox.shrink(); + } + + final remoteAsset = ref.watch(currentAssetNotifier) as RemoteAsset; + final userId = ref.watch(currentUserProvider)?.id; + final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAsset.id)); + + return assetAlbums.when( + data: (albums) { + if (albums.isEmpty) { + return const SizedBox.shrink(); + } + + albums.sortBy((a) => a.name); + + return Column( + spacing: 12, + children: [ + if (albums.isNotEmpty) + _SheetTile( + title: 'appears_in'.t(context: context).toUpperCase(), + titleStyle: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 24), + child: Column( + spacing: 12, + children: albums.map((album) { + final isOwner = album.ownerId == userId; + return AlbumTile( + album: album, + isOwner: isOwner, + onAlbumSelected: (album) async { + ref.invalidate(assetViewerProvider); + unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); + }, + ); + }).toList(), + ), + ), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { final asset = ref.watch(currentAssetNotifier); @@ -217,7 +279,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget { color: context.textTheme.bodyMedium?.color?.withAlpha(155), ), ), - const SizedBox(height: 64), + // Appears in (Albums) + _buildAppearsInList(ref, context), + // padding at the bottom to avoid cut-off + const SizedBox(height: 100), ], ); } diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index a0a98c34d7..b879b33f68 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart' import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/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/timeline/multiselect.provider.dart'; @@ -163,6 +164,7 @@ class _AssetTileWidget extends ConsumerWidget { initialIndex: assetIndex, timelineService: ref.read(timelineServiceProvider), heroOffset: heroOffset, + currentAlbum: ref.read(currentRemoteAlbumProvider), ), ), ); diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index 8388480974..1ddabc1604 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.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/services/local_album.service.dart'; @@ -40,3 +41,7 @@ final remoteAlbumProvider = NotifierProvider, String>( + (ref, assetId) => ref.watch(remoteAlbumServiceProvider).getAlbumsContainingAsset(assetId), +); diff --git a/mobile/lib/providers/infrastructure/current_album.provider.dart b/mobile/lib/providers/infrastructure/current_album.provider.dart index 0d95674ec7..6c2fc248ba 100644 --- a/mobile/lib/providers/infrastructure/current_album.provider.dart +++ b/mobile/lib/providers/infrastructure/current_album.provider.dart @@ -1,36 +1,30 @@ -import 'dart:async'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -final currentRemoteAlbumProvider = AutoDisposeNotifierProvider( +final currentRemoteAlbumScopedProvider = Provider((ref) => null); +final currentRemoteAlbumProvider = NotifierProvider( CurrentAlbumNotifier.new, + dependencies: [currentRemoteAlbumScopedProvider, remoteAlbumServiceProvider], ); -class CurrentAlbumNotifier extends AutoDisposeNotifier { - KeepAliveLink? _keepAliveLink; - StreamSubscription? _assetSubscription; - +class CurrentAlbumNotifier extends Notifier { @override - RemoteAlbum? build() => null; + RemoteAlbum? build() { + final album = ref.watch(currentRemoteAlbumScopedProvider); - void setAlbum(RemoteAlbum album) { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - state = album; + if (album == null) { + return null; + } - _assetSubscription = ref.watch(remoteAlbumServiceProvider).watchAlbum(album.id).listen((updatedAlbum) { + final watcher = ref.watch(remoteAlbumServiceProvider).watchAlbum(album.id).listen((updatedAlbum) { if (updatedAlbum != null) { state = updatedAlbum; } }); - _keepAliveLink = ref.keepAlive(); - } - void dispose() { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - state = null; + ref.onDispose(watcher.cancel); + + return album; } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 7554c7b1cf..5c0299c414 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -303,7 +303,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftBackupAlbumSelectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: LocalTimelineRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MainTimelineRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: RemoteAlbumRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: RemoteAlbumRoute.page, guards: [_authGuard]), AutoRoute( page: AssetViewerRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 4e488a30c7..4e60e4fb6a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -448,6 +448,7 @@ class AssetViewerRoute extends PageRouteInfo { required int initialIndex, required TimelineService timelineService, int? heroOffset, + RemoteAlbum? currentAlbum, List? children, }) : super( AssetViewerRoute.name, @@ -456,6 +457,7 @@ class AssetViewerRoute extends PageRouteInfo { initialIndex: initialIndex, timelineService: timelineService, heroOffset: heroOffset, + currentAlbum: currentAlbum, ), initialChildren: children, ); @@ -471,6 +473,7 @@ class AssetViewerRoute extends PageRouteInfo { initialIndex: args.initialIndex, timelineService: args.timelineService, heroOffset: args.heroOffset, + currentAlbum: args.currentAlbum, ); }, ); @@ -482,6 +485,7 @@ class AssetViewerRouteArgs { required this.initialIndex, required this.timelineService, this.heroOffset, + this.currentAlbum, }); final Key? key; @@ -492,9 +496,11 @@ class AssetViewerRouteArgs { final int? heroOffset; + final RemoteAlbum? currentAlbum; + @override String toString() { - return 'AssetViewerRouteArgs{key: $key, initialIndex: $initialIndex, timelineService: $timelineService, heroOffset: $heroOffset}'; + return 'AssetViewerRouteArgs{key: $key, initialIndex: $initialIndex, timelineService: $timelineService, heroOffset: $heroOffset, currentAlbum: $currentAlbum}'; } } @@ -706,36 +712,78 @@ class DownloadInfoRoute extends PageRouteInfo { /// generated route for /// [DriftActivitiesPage] -class DriftActivitiesRoute extends PageRouteInfo { - const DriftActivitiesRoute({List? children}) - : super(DriftActivitiesRoute.name, initialChildren: children); +class DriftActivitiesRoute extends PageRouteInfo { + DriftActivitiesRoute({ + Key? key, + required RemoteAlbum album, + List? children, + }) : super( + DriftActivitiesRoute.name, + args: DriftActivitiesRouteArgs(key: key, album: album), + initialChildren: children, + ); static const String name = 'DriftActivitiesRoute'; static PageInfo page = PageInfo( name, builder: (data) { - return const DriftActivitiesPage(); + final args = data.argsAs(); + return DriftActivitiesPage(key: args.key, album: args.album); }, ); } +class DriftActivitiesRouteArgs { + const DriftActivitiesRouteArgs({this.key, required this.album}); + + final Key? key; + + final RemoteAlbum album; + + @override + String toString() { + return 'DriftActivitiesRouteArgs{key: $key, album: $album}'; + } +} + /// generated route for /// [DriftAlbumOptionsPage] -class DriftAlbumOptionsRoute extends PageRouteInfo { - const DriftAlbumOptionsRoute({List? children}) - : super(DriftAlbumOptionsRoute.name, initialChildren: children); +class DriftAlbumOptionsRoute extends PageRouteInfo { + DriftAlbumOptionsRoute({ + Key? key, + required RemoteAlbum album, + List? children, + }) : super( + DriftAlbumOptionsRoute.name, + args: DriftAlbumOptionsRouteArgs(key: key, album: album), + initialChildren: children, + ); static const String name = 'DriftAlbumOptionsRoute'; static PageInfo page = PageInfo( name, builder: (data) { - return const DriftAlbumOptionsPage(); + final args = data.argsAs(); + return DriftAlbumOptionsPage(key: args.key, album: args.album); }, ); } +class DriftAlbumOptionsRouteArgs { + const DriftAlbumOptionsRouteArgs({this.key, required this.album}); + + final Key? key; + + final RemoteAlbum album; + + @override + String toString() { + return 'DriftAlbumOptionsRouteArgs{key: $key, album: $album}'; + } +} + /// generated route for /// [DriftAlbumsPage] class DriftAlbumsRoute extends PageRouteInfo { diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 7c8ddce265..d67362aac2 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -29,7 +28,6 @@ final deepLinkServiceProvider = Provider( // Below is used for beta timeline ref.watch(timelineFactoryProvider), ref.watch(beta_asset_provider.assetServiceProvider), - ref.watch(currentRemoteAlbumProvider.notifier), ref.watch(remoteAlbumServiceProvider), ref.watch(driftMemoryServiceProvider), ), @@ -46,7 +44,6 @@ class DeepLinkService { /// Used for beta timeline final TimelineFactory _betaTimelineFactory; final beta_asset_service.AssetService _betaAssetService; - final CurrentAlbumNotifier _betaCurrentAlbumNotifier; final RemoteAlbumService _betaRemoteAlbumService; final DriftMemoryService _betaMemoryServiceProvider; @@ -58,7 +55,6 @@ class DeepLinkService { this._currentAlbum, this._betaTimelineFactory, this._betaAssetService, - this._betaCurrentAlbumNotifier, this._betaRemoteAlbumService, this._betaMemoryServiceProvider, ); @@ -176,7 +172,6 @@ class DeepLinkService { return null; } - _betaCurrentAlbumNotifier.setAlbum(album); return RemoteAlbumRoute(album: album); } else { // TODO: Remove this when beta is default diff --git a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart index 9f88b23f92..8913e94136 100644 --- a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart +++ b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart @@ -25,7 +25,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget { } return GestureDetector( - onTap: () => context.pushRoute(const DriftAlbumOptionsRoute()), + onTap: () => context.pushRoute(DriftAlbumOptionsRoute(album: currentAlbum)), child: SizedBox( height: 50, child: ListView.builder( diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index f75dd6e803..c0661bad48 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart'; class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { @@ -89,7 +88,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])), + onPressed: () => context.maybePop(), ), actions: [ if (widget.onToggleAlbumOrder != null) From 9f0b5790af1b35c71e4aa649135fcddb7e364c23 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 18:16:18 +0100 Subject: [PATCH 042/105] chore(deps): update dependency @types/node to ^22.18.12 (#23305) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package.json | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- server/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/package.json b/cli/package.json index 0976ac6ecd..1776482f5b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.18.10", + "@types/node": "^22.18.12", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package.json b/e2e/package.json index 6b8eb446b6..ffbf150617 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^22.18.10", + "@types/node": "^22.18.12", "@types/oidc-provider": "^9.0.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 459e619a02..c35a363e9d 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.18.10", + "@types/node": "^22.18.12", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f420720e5b..4bd4d9e8e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^22.18.10 + specifier: ^22.18.12 version: 22.18.12 '@vitest/coverage-v8': specifier: ^3.0.0 @@ -211,7 +211,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^22.18.10 + specifier: ^22.18.12 version: 22.18.12 '@types/oidc-provider': specifier: ^9.0.0 @@ -293,7 +293,7 @@ importers: version: 1.0.4 devDependencies: '@types/node': - specifier: ^22.18.10 + specifier: ^22.18.12 version: 22.18.12 typescript: specifier: ^5.3.3 @@ -582,7 +582,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^22.18.10 + specifier: ^22.18.12 version: 22.18.12 '@types/nodemailer': specifier: ^7.0.0 diff --git a/server/package.json b/server/package.json index 7ff18ca2e1..cb74a5c39b 100644 --- a/server/package.json +++ b/server/package.json @@ -129,7 +129,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^22.18.10", + "@types/node": "^22.18.12", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", From 3edcb180eb5c8b194e6ffc42136b560679a27756 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 28 Oct 2025 12:16:36 -0500 Subject: [PATCH 043/105] fix: flaky mobile sync api tests (#23324) --- .../sync_api_repository_test.dart | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 467e19bf3f..660b8206bb 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -80,6 +80,7 @@ void main() { int onDataCallCount = 0; bool abortWasCalledInCallback = false; List receivedEventsBatch1 = []; + final Completer firstBatchReceived = Completer(); Future onDataCallback(List events, Function() abort, Function() _) async { onDataCallCount++; @@ -87,6 +88,7 @@ void main() { receivedEventsBatch1 = events; abort(); abortWasCalledInCallback = true; + firstBatchReceived.complete(); } else { fail("onData called more than once after abort was invoked"); } @@ -94,7 +96,8 @@ void main() { final streamChangesFuture = streamChanges(onDataCallback); - await pumpEventQueue(); + // Give the stream subscription time to start (longer delay to account for mock delay) + await Future.delayed(const Duration(milliseconds: 50)); for (int i = 0; i < testBatchSize; i++) { responseStreamController.add( @@ -104,6 +107,11 @@ void main() { ); } + await firstBatchReceived.future.timeout( + const Duration(seconds: 5), + onTimeout: () => fail('First batch was not processed within timeout'), + ); + for (int i = testBatchSize; i < testBatchSize * 2; i++) { responseStreamController.add( utf8.encode( @@ -124,12 +132,14 @@ void main() { test('streamChanges does not process remaining lines in finally block if aborted', () async { int onDataCallCount = 0; bool abortWasCalledInCallback = false; + final Completer firstBatchReceived = Completer(); Future onDataCallback(List events, Function() abort, Function() _) async { onDataCallCount++; if (onDataCallCount == 1) { abort(); abortWasCalledInCallback = true; + firstBatchReceived.complete(); } else { fail("onData called more than once after abort was invoked"); } @@ -137,7 +147,7 @@ void main() { final streamChangesFuture = streamChanges(onDataCallback); - await pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 50)); for (int i = 0; i < testBatchSize; i++) { responseStreamController.add( @@ -147,6 +157,11 @@ void main() { ); } + await firstBatchReceived.future.timeout( + const Duration(seconds: 5), + onTimeout: () => fail('First batch was not processed within timeout'), + ); + // emit a single event to skip batching and trigger finally responseStreamController.add( utf8.encode( @@ -166,13 +181,17 @@ void main() { int onDataCallCount = 0; List receivedEventsBatch1 = []; List receivedEventsBatch2 = []; + final Completer firstBatchReceived = Completer(); + final Completer secondBatchReceived = Completer(); Future onDataCallback(List events, Function() _, Function() __) async { onDataCallCount++; if (onDataCallCount == 1) { receivedEventsBatch1 = events; + firstBatchReceived.complete(); } else if (onDataCallCount == 2) { receivedEventsBatch2 = events; + secondBatchReceived.complete(); } else { fail("onData called more than expected"); } @@ -180,7 +199,7 @@ void main() { final streamChangesFuture = streamChanges(onDataCallback); - await pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 50)); // Batch 1 for (int i = 0; i < testBatchSize; i++) { @@ -191,7 +210,11 @@ void main() { ); } - // Partial Batch 2 + await firstBatchReceived.future.timeout( + const Duration(seconds: 5), + onTimeout: () => fail('First batch was not processed within timeout'), + ); + responseStreamController.add( utf8.encode( _createJsonLine(SyncEntityType.userDeleteV1.toString(), SyncUserDeleteV1(userId: "user100").toJson(), 'ack100'), @@ -199,6 +222,12 @@ void main() { ); await responseStreamController.close(); + + await secondBatchReceived.future.timeout( + const Duration(seconds: 5), + onTimeout: () => fail('Second batch was not processed within timeout'), + ); + await expectLater(streamChangesFuture, completes); expect(onDataCallCount, 2); @@ -217,7 +246,7 @@ void main() { final streamChangesFuture = streamChanges(onDataCallback); - await pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 50)); responseStreamController.add( utf8.encode( From 9676da27c92984f1f9b0cbe1fd94e7b5454e1d24 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 29 Oct 2025 00:23:48 +0530 Subject: [PATCH 044/105] fix: clear temp cache on iOS before uploads (#23326) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../repositories/storage.repository.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 164fa04529..9532025d58 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -89,5 +90,17 @@ class StorageRepository { } catch (error, stackTrace) { log.warning("Error clearing cache", error, stackTrace); } + + if (!CurrentPlatform.isIOS) { + return; + } + + try { + if (await Directory.systemTemp.exists()) { + await Directory.systemTemp.delete(recursive: true); + } + } catch (error, stackTrace) { + log.warning("Error deleting temporary directory", error, stackTrace); + } } } From 106effca2e8d4bcd584c4ddad8e90ac8a35b40d1 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Oct 2025 13:54:41 -0500 Subject: [PATCH 045/105] feat: check server feature to render OCR search option (#23325) --- .../server_info/server_features.model.dart | 15 +++++--- .../pages/search/drift_search.page.dart | 32 +++++++++------- mobile/lib/utils/openapi_patching.dart | 5 +++ mobile/lib/widgets/common/feature_check.dart | 38 +++++++++++++++++++ 4 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 mobile/lib/widgets/common/feature_check.dart diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart index 20b9f29619..049628a8d2 100644 --- a/mobile/lib/models/server_info/server_features.model.dart +++ b/mobile/lib/models/server_info/server_features.model.dart @@ -5,33 +5,37 @@ class ServerFeatures { final bool map; final bool oauthEnabled; final bool passwordLogin; + final bool ocr; const ServerFeatures({ required this.trash, required this.map, required this.oauthEnabled, required this.passwordLogin, + this.ocr = false, }); - ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin}) { + ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin, bool? ocr}) { return ServerFeatures( trash: trash ?? this.trash, map: map ?? this.map, oauthEnabled: oauthEnabled ?? this.oauthEnabled, passwordLogin: passwordLogin ?? this.passwordLogin, + ocr: ocr ?? this.ocr, ); } @override String toString() { - return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin)'; + return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr)'; } ServerFeatures.fromDto(ServerFeaturesDto dto) : trash = dto.trash, map = dto.map, oauthEnabled = dto.oauth, - passwordLogin = dto.passwordLogin; + passwordLogin = dto.passwordLogin, + ocr = dto.ocr; @override bool operator ==(covariant ServerFeatures other) { @@ -40,11 +44,12 @@ class ServerFeatures { return other.trash == trash && other.map == map && other.oauthEnabled == oauthEnabled && - other.passwordLogin == passwordLogin; + other.passwordLogin == passwordLogin && + other.ocr == ocr; } @override int get hashCode { - return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode; + return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode; } } diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 965e31678e..d631395465 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/feature_check.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; @@ -503,23 +504,26 @@ class DriftSearchPage extends HookConsumerWidget { searchHintText.value = 'search_by_description_example'.t(context: context); }, ), - MenuItemButton( - child: ListTile( - leading: const Icon(Icons.document_scanner_outlined), - title: Text( - 'search_by_ocr'.t(context: context), - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null, + FeatureCheck( + feature: (features) => features.ocr, + child: MenuItemButton( + child: ListTile( + leading: const Icon(Icons.document_scanner_outlined), + title: Text( + 'search_by_ocr'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null, + ), ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.ocr, ), - selectedColor: context.colorScheme.primary, - selected: textSearchType.value == TextSearchType.ocr, + onPressed: () { + textSearchType.value = TextSearchType.ocr; + searchHintText.value = 'search_by_ocr_example'.t(context: context); + }, ), - onPressed: () { - textSearchType.value = TextSearchType.ocr; - searchHintText.value = 'search_by_ocr_example'.t(context: context); - }, ), ], ), diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 33199d5225..0a3fa7e91d 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -46,6 +46,11 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'hasProfileImage', false); } + case 'ServerFeaturesDto': + if (value is Map) { + addDefault(value, 'ocr', false); + } + break; } } diff --git a/mobile/lib/widgets/common/feature_check.dart b/mobile/lib/widgets/common/feature_check.dart new file mode 100644 index 0000000000..ebaa0acfe7 --- /dev/null +++ b/mobile/lib/widgets/common/feature_check.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/server_info/server_features.model.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; + +/// A utility widget that conditionally renders its child based on a server feature flag. +/// +/// Example usage: +/// ```dart +/// FeatureCheck( +/// feature: (features) => features.ocr, +/// child: Text('OCR is enabled'), +/// fallback: Text('OCR is not available'), +/// ) +/// ``` +class FeatureCheck extends ConsumerWidget { + /// A function that extracts the specific feature flag from ServerFeatures + final bool Function(ServerFeatures) feature; + + /// The widget to display when the feature is enabled + final Widget child; + + /// Optional widget to display when the feature is disabled + final Widget? fallback; + + const FeatureCheck({super.key, required this.feature, required this.child, this.fallback}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverFeatures = ref.watch(serverInfoProvider.select((s) => s.serverFeatures)); + final isFeatureEnabled = feature(serverFeatures); + if (isFeatureEnabled) { + return child; + } + + return fallback ?? const SizedBox.shrink(); + } +} From 52596255c8dc897f74ba61cd2f7521b554b15e69 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 28 Oct 2025 15:09:11 -0400 Subject: [PATCH 046/105] feat: toasts (#23298) --- .../web/specs/asset-viewer/navbar.e2e-spec.ts | 2 +- .../specs/asset-viewer/slideshow.e2e-spec.ts | 2 +- i18n/en.json | 2 +- pnpm-lock.yaml | 10 +- web/package.json | 2 +- web/src/lib/components/ToastAction.svelte | 33 +++++ .../admin-settings/AdminSettings.svelte | 21 +-- .../admin-settings/AuthSettings.svelte | 8 +- .../NotificationSettings.svelte | 11 +- .../components/album-page/albums-list.svelte | 32 +++-- .../asset-viewer/actions/delete-action.svelte | 18 +-- .../actions/favorite-action.svelte | 11 +- .../actions/restore-action.svelte | 12 +- .../actions/set-album-cover-action.svelte | 11 +- .../actions/set-person-featured-action.svelte | 7 +- .../asset-viewer/activity-viewer.svelte | 8 +- .../asset-viewer/asset-viewer.svelte | 4 +- .../detail-panel-description.svelte | 12 +- .../face-editor/face-editor.svelte | 7 +- .../asset-viewer/photo-viewer.svelte | 5 +- .../manage-people-visibility.spec.ts | 9 +- .../manage-people-visibility.svelte | 16 +-- .../faces-page/merge-face-selector.svelte | 13 +- .../faces-page/person-side-panel.svelte | 8 +- .../faces-page/unmerge-face-selector.svelte | 16 +-- .../forms/library-import-paths-form.svelte | 15 +-- .../forms/library-scan-settings-form.svelte | 2 +- web/src/lib/components/jobs/JobsPanel.svelte | 11 +- .../memory-page/memory-viewer.svelte | 13 +- .../individual-shared-viewer.svelte | 8 +- .../navigation-bar/notification-panel.svelte | 9 +- .../__tests__/notification-card.spec.ts | 86 ------------ .../notification-component-test.svelte | 9 -- .../__tests__/notification-list.spec.ts | 41 ------ .../notification/notification-card.svelte | 125 ------------------ .../notification/notification-list.svelte | 25 ---- .../notification/notification.ts | 87 ------------ .../shared-components/upload-panel.svelte | 18 +-- .../timeline/actions/AssetJobActions.svelte | 7 +- .../timeline/actions/FavoriteAction.svelte | 13 +- .../actions/RemoveFromAlbumAction.svelte | 18 +-- .../actions/RemoveFromSharedLinkAction.svelte | 10 +- .../timeline/actions/RestoreAction.svelte | 13 +- .../PinCodeChangeForm.svelte | 13 +- .../PinCodeCreateForm.svelte | 13 +- .../change-password-settings.svelte | 18 +-- .../user-settings-page/device-list.svelte | 12 +- .../download-settings.svelte | 10 +- .../feature-settings.svelte | 10 +- .../notifications-settings.svelte | 15 +-- .../user-settings-page/oauth-settings.svelte | 17 +-- .../partner-settings.svelte | 2 +- .../user-api-key-list.svelte | 15 +-- .../user-profile-settings.svelte | 13 +- web/src/lib/modals/AlbumOptionsModal.svelte | 15 +-- web/src/lib/modals/AlbumUsersModal.svelte | 17 +-- web/src/lib/modals/ApiKeyModal.svelte | 30 +++-- web/src/lib/modals/AvatarEditModal.svelte | 8 +- web/src/lib/modals/JobCreateModal.svelte | 8 +- .../modals/PersonEditBirthDateModal.svelte | 8 +- .../modals/PersonMergeSuggestionModal.svelte | 12 +- web/src/lib/modals/PinCodeResetModal.svelte | 7 +- .../modals/ProfileImageCropperModal.svelte | 15 +-- .../lib/modals/SharedLinkCreateModal.svelte | 19 ++- web/src/lib/modals/TagCreateModal.svelte | 11 +- web/src/lib/modals/TagEditModal.svelte | 11 +- web/src/lib/utils.ts | 4 +- web/src/lib/utils/actions.ts | 35 +++-- web/src/lib/utils/asset-utils.ts | 123 +++++++---------- web/src/lib/utils/handle-error.ts | 4 +- .../[[assetId=id]]/+page.svelte | 16 +-- web/src/routes/(user)/people/+page.svelte | 21 +-- .../[[assetId=id]]/+page.svelte | 24 +--- .../shared-links/[[id=id]]/+page.svelte | 8 +- .../[[assetId=id]]/+page.svelte | 17 +-- .../[[assetId=id]]/+page.svelte | 21 +-- web/src/routes/+layout.svelte | 6 +- .../admin/library-management/+page.svelte | 23 +--- web/src/routes/admin/users/+page.svelte | 11 +- web/src/routes/admin/users/[id]/+page.svelte | 8 +- 80 files changed, 341 insertions(+), 1069 deletions(-) create mode 100644 web/src/lib/components/ToastAction.svelte delete mode 100644 web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts delete mode 100644 web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte delete mode 100644 web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts delete mode 100644 web/src/lib/components/shared-components/notification/notification-card.svelte delete mode 100644 web/src/lib/components/shared-components/notification/notification-list.svelte delete mode 100644 web/src/lib/components/shared-components/notification/notification.ts diff --git a/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts index 4f20e2db19..8fcd1bbdb4 100644 --- a/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts @@ -59,7 +59,7 @@ test.describe('Asset Viewer Navbar', () => { await page.goto(`/photos/${asset.id}`); await page.waitForSelector('#immich-asset-viewer'); await page.keyboard.press('f'); - await expect(page.locator('#notification-list').getByTestId('message')).toHaveText('Added to favorites'); + await expect(page.getByText('Added to favorites')).toBeVisible(); }); }); }); diff --git a/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts index 72bb3c5c59..c8cbc21588 100644 --- a/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts @@ -51,6 +51,6 @@ test.describe('Slideshow', () => { await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible(); await page.keyboard.press('f'); - await expect(page.locator('#notification-list')).not.toBeVisible(); + await expect(page.getByText('Added to favorites')).not.toBeVisible(); }); }); diff --git a/i18n/en.json b/i18n/en.json index d0a4da3de5..30c8949aef 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -906,7 +906,6 @@ "edit_tag": "Edit tag", "edit_title": "Edit Title", "edit_user": "Edit user", - "edited": "Edited", "editor": "Editor", "editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_title": "Close editor?", @@ -1717,6 +1716,7 @@ "running": "Running", "save": "Save", "save_to_gallery": "Save to gallery", + "saved": "Saved", "saved_api_key": "Saved API Key", "saved_profile": "Saved profile", "saved_settings": "Saved settings", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bd4d9e8e0..53dca7310a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -684,8 +684,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.37.1 - version: 0.37.1(@internationalized/date@3.8.2)(svelte@5.40.1) + specifier: ^0.39.1 + version: 0.39.1(@internationalized/date@3.8.2)(svelte@5.40.1) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -2732,8 +2732,8 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} - '@immich/ui@0.37.1': - resolution: {integrity: sha512-8S9KsyqyRcNgRHeBU8G3qMQ7D7fN4u9I31jjRc9c3s2tkiYucASofPJdcFdmGZnKLX5fIj+yofxiNZV9tVitOg==} + '@immich/ui@0.39.1': + resolution: {integrity: sha512-sal9VyFcmLRHE+NJh122dnmjfwlPOeZCi3yIsDzuI5xNMEUtNJ8MlXRE7hgrKU3FOLmy2QLhcI+oEJchCT+Ibg==} peerDependencies: svelte: ^5.0.0 @@ -14190,7 +14190,7 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/ui@0.37.1(@internationalized/date@3.8.2)(svelte@5.40.1)': + '@immich/ui@0.39.1(@internationalized/date@3.8.2)(svelte@5.40.1)': dependencies: '@mdi/js': 7.4.47 bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.40.1) diff --git a/web/package.json b/web/package.json index dfcd7ef28a..a037b6cea2 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.37.1", + "@immich/ui": "^0.39.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/lib/components/ToastAction.svelte b/web/src/lib/components/ToastAction.svelte new file mode 100644 index 0000000000..e77f92af5a --- /dev/null +++ b/web/src/lib/components/ToastAction.svelte @@ -0,0 +1,33 @@ + + + + + {#if button} +
+ +
+ {/if} +
+
diff --git a/web/src/lib/components/admin-settings/AdminSettings.svelte b/web/src/lib/components/admin-settings/AdminSettings.svelte index 199db0b571..54be8bea96 100644 --- a/web/src/lib/components/admin-settings/AdminSettings.svelte +++ b/web/src/lib/components/admin-settings/AdminSettings.svelte @@ -1,15 +1,12 @@ - -Notification message with
link diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts b/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts deleted file mode 100644 index df1e5a9f82..0000000000 --- a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getAnimateMock } from '$lib/__mocks__/animate.mock'; -import '@testing-library/jest-dom'; -import { render, waitFor, type RenderResult } from '@testing-library/svelte'; -import { get } from 'svelte/store'; -import { NotificationType, notificationController } from '../notification'; -import NotificationList from '../notification-list.svelte'; - -function _getNotificationListElement(): HTMLAnchorElement | null { - return document.body.querySelector('#notification-list'); -} - -describe('NotificationList component', () => { - beforeAll(() => { - Element.prototype.animate = getAnimateMock(); - }); - - afterAll(() => { - vi.unstubAllGlobals(); - }); - - it('shows a notification when added and closes it automatically after the delay timeout', async () => { - const sut: RenderResult = render(NotificationList, { intro: false }); - const status = await sut.findAllByRole('status'); - - expect(status).toHaveLength(1); - expect(_getNotificationListElement()).not.toBeInTheDocument(); - - notificationController.show({ - message: 'Notification', - type: NotificationType.Info, - timeout: 1, - }); - - await waitFor(() => expect(_getNotificationListElement()).toBeInTheDocument()); - await waitFor(() => expect(_getNotificationListElement()?.children).toHaveLength(1)); - expect(get(notificationController.notificationList)).toHaveLength(1); - - await waitFor(() => expect(_getNotificationListElement()).not.toBeInTheDocument()); - expect(get(notificationController.notificationList)).toHaveLength(0); - }); -}); diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte deleted file mode 100644 index 581e051073..0000000000 --- a/web/src/lib/components/shared-components/notification/notification-card.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - - -
-
-
- -

- {#if notification.type == NotificationType.Error}{$t('error')} - {:else if notification.type == NotificationType.Warning}{$t('warning')} - {:else if notification.type == NotificationType.Info}{$t('info')}{/if} -

-
- -
- -

- {#if isComponentNotification(notification)} - - {:else} - {notification.message} - {/if} -

- - {#if notification.button} -

- -

- {/if} -
diff --git a/web/src/lib/components/shared-components/notification/notification-list.svelte b/web/src/lib/components/shared-components/notification/notification-list.svelte deleted file mode 100644 index fa86d727db..0000000000 --- a/web/src/lib/components/shared-components/notification/notification-list.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - -
- {#if $notificationList.length > 0} -
- {#each $notificationList as notification (notification.id)} -
- -
- {/each} -
- {/if} -
-
diff --git a/web/src/lib/components/shared-components/notification/notification.ts b/web/src/lib/components/shared-components/notification/notification.ts deleted file mode 100644 index 79b1edd1a9..0000000000 --- a/web/src/lib/components/shared-components/notification/notification.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { Component as ComponentType } from 'svelte'; -import { writable } from 'svelte/store'; - -export enum NotificationType { - Info = 'Info', - Error = 'Error', - Warning = 'Warning', -} - -export type NotificationButton = { - text: string; - onClick: () => unknown; -}; - -export type Notification = { - id: number; - type: NotificationType; - message: string; - /** The action to take when the notification is clicked */ - action: NotificationAction; - button?: NotificationButton; - /** Timeout in milliseconds */ - timeout: number; -}; - -type DiscardAction = { type: 'discard' }; -type NoopAction = { type: 'noop' }; - -export type NotificationAction = DiscardAction | NoopAction; - -type Props = Record; -type Component = { - type: ComponentType; - props: T; -}; - -type BaseNotificationOptions = Partial> & Pick; - -export type NotificationOptions = BaseNotificationOptions; -export type ComponentNotificationOptions = BaseNotificationOptions< - ComponentNotification, - 'component' ->; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ComponentNotification = Omit & { - component: Component; -}; - -export const isComponentNotification = ( - notification: Notification | ComponentNotification, -): notification is ComponentNotification => { - return 'component' in notification; -}; - -function createNotificationList() { - const notificationList = writable<(Notification | ComponentNotification)[]>([]); - let count = 1; - - const show = (options: T extends Props ? ComponentNotificationOptions : NotificationOptions) => { - notificationList.update((currentList) => { - currentList.push({ - id: count++, - type: NotificationType.Info, - action: { - type: options.button ? 'noop' : 'discard', - }, - timeout: 3000, - ...options, - }); - - return currentList; - }); - }; - - const removeNotificationById = (id: number) => { - notificationList.update((currentList) => currentList.filter((n) => n.id !== id)); - }; - - return { - show, - removeNotificationById, - notificationList, - }; -} - -export const notificationController = createNotificationList(); diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index 68b39e163d..91f6609e10 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -2,12 +2,11 @@ import { locale } from '$lib/stores/preferences.store'; import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadExecutionQueue } from '$lib/utils/file-uploader'; - import { Icon, IconButton } from '@immich/ui'; + import { Icon, IconButton, toastManager } from '@immich/ui'; import { mdiCancel, mdiCloudUploadOutline, mdiCog, mdiWindowMinimize } from '@mdi/js'; import { t } from 'svelte-i18n'; import { quartInOut } from 'svelte/easing'; import { fade, scale } from 'svelte/transition'; - import { notificationController, NotificationType } from './notification/notification'; import UploadAssetPreview from './upload-asset-preview.svelte'; let showDetail = $state(false); @@ -29,21 +28,12 @@ out:fade={{ duration: 250 }} onoutroend={() => { if ($stats.errors > 0) { - notificationController.show({ - message: $t('upload_errors', { values: { count: $stats.errors } }), - type: NotificationType.Warning, - }); + toastManager.danger($t('upload_errors', { values: { count: $stats.errors } })); } else if ($stats.success > 0) { - notificationController.show({ - message: $t('upload_success'), - type: NotificationType.Info, - }); + toastManager.success($t('upload_success')); } if ($stats.duplicates > 0) { - notificationController.show({ - message: $t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }), - type: NotificationType.Warning, - }); + toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } })); } uploadAssetsStore.reset(); }} diff --git a/web/src/lib/components/timeline/actions/AssetJobActions.svelte b/web/src/lib/components/timeline/actions/AssetJobActions.svelte index cee1b367be..249b3c5d14 100644 --- a/web/src/lib/components/timeline/actions/AssetJobActions.svelte +++ b/web/src/lib/components/timeline/actions/AssetJobActions.svelte @@ -1,13 +1,10 @@ diff --git a/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte b/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte index c0ea55fdc8..973760ac45 100644 --- a/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte +++ b/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte @@ -3,10 +3,9 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { removeSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk'; - import { IconButton, modalManager } from '@immich/ui'; + import { IconButton, modalManager, toastManager } from '@immich/ui'; import { mdiDeleteOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - import { NotificationType, notificationController } from '../../shared-components/notification/notification'; interface Props { sharedLink: SharedLinkResponseDto; @@ -45,12 +44,7 @@ } const count = results.filter((item) => item.success).length; - - notificationController.show({ - type: NotificationType.Info, - message: $t('assets_removed_count', { values: { count } }), - }); - + toastManager.success($t('assets_removed_count', { values: { count } })); clearSelect(); } catch (error) { handleError(error, $t('errors.unable_to_remove_assets_from_shared_link')); diff --git a/web/src/lib/components/timeline/actions/RestoreAction.svelte b/web/src/lib/components/timeline/actions/RestoreAction.svelte index 7550b3dd54..ec70f01cd9 100644 --- a/web/src/lib/components/timeline/actions/RestoreAction.svelte +++ b/web/src/lib/components/timeline/actions/RestoreAction.svelte @@ -1,13 +1,9 @@ + +
-
+

{$t('year')}

    diff --git a/web/src/lib/components/admin-settings/SupportedVariablesPanel.svelte b/web/src/lib/components/admin-settings/SupportedVariablesPanel.svelte index e39f0da771..74a05b553f 100644 --- a/web/src/lib/components/admin-settings/SupportedVariablesPanel.svelte +++ b/web/src/lib/components/admin-settings/SupportedVariablesPanel.svelte @@ -7,7 +7,7 @@
-
+

{$t('filename')}

    diff --git a/web/src/lib/components/album-page/albums-table-row.svelte b/web/src/lib/components/album-page/albums-table-row.svelte index 7cb30c8a8b..865eac8366 100644 --- a/web/src/lib/components/album-page/albums-table-row.svelte +++ b/web/src/lib/components/album-page/albums-table-row.svelte @@ -32,7 +32,7 @@ goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))} {oncontextmenu} > diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index d6508bbb6f..73b311769a 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -141,14 +141,14 @@
-
{reaction.comment}
+
{reaction.comment}
{#if assetId === undefined && reaction.assetId} Profile picture of {reaction.user.name}, who commented on this asset @@ -197,11 +197,11 @@
{#if assetId === undefined && reaction.assetId} Profile picture of {reaction.user.name}, who liked this asset diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7bc7f6e000..8ff7a00710 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -593,7 +593,7 @@ {#if stackedAsset.id === asset.id}
-
+
{/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte index cb6e47985a..ad793bd475 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -31,13 +31,13 @@
{:else if description}
-

{description}

+

{description}

{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index ccb84af041..6d4f6a97c1 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -199,7 +199,7 @@ {#each people as person, index (person.id)} {#if showingHiddenPeople || !person.isHidden}
{#if latlng && $featureFlags.loaded && $featureFlags.map} -
+
{#await import('$lib/components/shared-components/map/map.svelte')} {#await delay(timeToLoadTheMap) then} @@ -511,7 +511,7 @@
{album.albumName} {$t('downloading')} -
+
{#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)} {@const download = downloadManager.assets[downloadKey]}
@@ -31,10 +31,10 @@ {/if}
-
-
+
+
-

+

{(download.percentage / 100).toLocaleString($locale, { style: 'percent' })} diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 24cf067989..01b2982efb 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -322,7 +322,7 @@

-
+
{#if filteredCandidates.length > 0}
{#each filteredCandidates as person (person.id)} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index a772a88d54..d88609f7bb 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -257,7 +257,7 @@ {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
{/each} diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 2a07aa2900..d73e9b8218 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -68,7 +68,7 @@ circle && 'rounded-full', shadow && 'shadow-lg', (circle || !heightStyle) && 'aspect-square', - border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary', + border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary', brokenAssetClass, ] .filter(Boolean) diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 1047f4a2df..994d741605 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -232,7 +232,7 @@ >
diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 7f829d60a5..9eb7c78666 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -73,7 +73,7 @@
{#if !searchFaces} @@ -157,7 +157,7 @@ {#each showPeople as person (person.id)} {#if !editedFace.person || person.id !== editedFace.person.id}
-